diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AppHost.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AppHost.cs index 1a7877b3d95..64767eaa768 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AppHost.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/AppHost.cs @@ -3,10 +3,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var storage = builder.AddAzureStorage("storage").RunAsEmulator(container => -{ - container.WithDataBindMount(); -}); +var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("blobs"); storage.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); @@ -14,10 +11,7 @@ var myqueue = storage.AddQueue("myqueue", queueName: "my-queue"); -var storage2 = builder.AddAzureStorage("storage2").RunAsEmulator(container => -{ - container.WithDataBindMount(); -}); +var storage2 = builder.AddAzureStorage("storage2"); var blobContainer2 = storage2.AddBlobContainer("foocontainer", blobContainerName: "foo-container"); @@ -38,4 +32,3 @@ #endif builder.Build().Run(); - diff --git a/playground/bicep/BicepSample.AppHost/AppHost.cs b/playground/bicep/BicepSample.AppHost/AppHost.cs index 1bf9abe3c2b..8c6a88b9a1e 100644 --- a/playground/bicep/BicepSample.AppHost/AppHost.cs +++ b/playground/bicep/BicepSample.AppHost/AppHost.cs @@ -26,8 +26,8 @@ var kv = builder.AddAzureKeyVault("kv3"); var appConfig = builder.AddAzureAppConfiguration("appConfig").WithParameter("sku", "standard"); -var storage = builder.AddAzureStorage("storage"); - // .RunAsEmulator(); +var storage = builder.AddAzureStorage("storage") + .RunAsEmulator(); var blobs = storage.AddBlobs("blob"); var tables = storage.AddTables("table"); diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs index f50ea9a0c24..c8ad92d2f6e 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs @@ -1,8 +1,12 @@ // 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. + +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting.Azure; @@ -34,10 +38,33 @@ public static IResourceBuilder AddAzureEnvironment(thi var resource = new AzureEnvironmentResource(resourceName, locationParam, resourceGroupName, principalId); if (builder.ExecutionContext.IsRunMode) { - // Return a builder that isn't added to the top-level application builder - // so it doesn't surface as a resource. - return builder.CreateResourceBuilder(resource); + var resourceBuilder = builder.AddResource(resource) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = nameof(AzureEnvironmentResource), + CreationTimeStamp = DateTime.UtcNow, + State = KnownResourceStates.NotStarted, + Properties = ImmutableArray.Empty + }); + + foreach (var command in AzureProvisioningController.EnvironmentCommandDefinitions) + { + resourceBuilder.WithCommand( + command.Name, + command.DisplayName, + executeCommand: context => context.ServiceProvider.GetRequiredService().ExecuteEnvironmentCommandAsync(command.Command, context), + commandOptions: new CommandOptions + { + Description = command.Description, + ConfirmationMessage = command.ConfirmationMessage, + IconName = command.IconName, + IconVariant = command.IconVariant, + IsHighlighted = command.IsHighlighted, + UpdateState = context => context.ServiceProvider.GetRequiredService().GetEnvironmentCommandState() + }); + } + return resourceBuilder.ExcludeFromManifest(); } // In publish mode, add the resource to the application model diff --git a/src/Aspire.Hosting.Azure/AzureProvisioningController.cs b/src/Aspire.Hosting.Azure/AzureProvisioningController.cs new file mode 100644 index 00000000000..e3ca90005b0 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzureProvisioningController.cs @@ -0,0 +1,1743 @@ +// 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. +#pragma warning disable ASPIREINTERACTION001 // 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.Nodes; +using System.Threading.Channels; +using Aspire.Hosting.Eventing; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Azure.Provisioning; +using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Azure.Resources; +using Azure; +using Azure.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Azure; + +/// +/// Coordinates Azure run-mode provisioning, recovery, and drift detection through a single serialized control loop. +/// +/// +/// +/// 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. +/// +/// +/// 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 — the only +/// two completion paths — so dependent resources unblock as soon as their prerequisites finish, not when the +/// entire batch completes. +/// +/// +/// 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) and provides the Azure identity properties shown on the AzureEnvironmentResource. +/// +/// +/// 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. +/// +/// +/// Drift detection runs on a periodic timer. It probes ARM to verify each running resource still exists and +/// marks missing resources as "Missing in Azure" / the environment as "Drifted". The drift monitor queues at +/// most one check at a time through the same serialized channel. +/// +/// +/// 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. +/// +/// +internal sealed class AzureProvisioningController( + IConfiguration configuration, + IOptions provisionerOptions, + IServiceProvider serviceProvider, + IBicepProvisioner bicepProvisioner, + IDeploymentStateManager deploymentStateManager, + IDistributedApplicationEventing eventing, + IProvisioningContextProvider provisioningContextProvider, + IAzureProvisioningOptionsManager provisioningOptionsManager, + ResourceNotificationService notificationService, + ResourceLoggerService loggerService, + ILogger logger) +{ + internal const string ForgetStateCommandName = "forget-state"; + internal const string ChangeResourceLocationCommandName = "change-location"; + 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 const string MissingInAzureState = "Missing in Azure"; + internal const string DriftedState = "Drifted"; + + private static readonly string[] s_resettableProperties = + [ + "azure.subscription.id", + "azure.resource.group", + "azure.tenant.domain", + "azure.tenant.id", + "azure.location", + CustomResourceKnownProperties.Source + ]; + + private readonly Channel _operationChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + private readonly ILogger _logger = logger; + private readonly object _operationStateLock = new(); + private AzureControllerState _state = AzureControllerState.Empty; + private int _operationLoopStarted; + private int _driftMonitorStarted; + 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); + + internal static ImmutableArray EnvironmentCommandDefinitions { get; } = + [ + new( + AzureEnvironmentCommand.ResetProvisioningState, + ResetProvisioningStateCommandName, + AzureProvisioningStrings.ResetProvisioningStateCommandName, + AzureProvisioningStrings.ResetProvisioningStateCommandDescription, + AzureProvisioningStrings.ResetProvisioningStateCommandConfirmation, + "ArrowSync", + IconVariant.Regular, + IsHighlighted: true), + new( + AzureEnvironmentCommand.ChangeAzureContext, + ChangeAzureContextCommandName, + AzureProvisioningStrings.ChangeAzureContextCommandName, + AzureProvisioningStrings.ChangeAzureContextCommandDescription, + AzureProvisioningStrings.ChangeAzureContextCommandConfirmation, + "Edit", + IconVariant.Regular, + IsHighlighted: true), + new( + AzureEnvironmentCommand.ReprovisionAll, + ReprovisionAllCommandName, + AzureProvisioningStrings.ReprovisionAllCommandName, + AzureProvisioningStrings.ReprovisionAllCommandDescription, + AzureProvisioningStrings.ReprovisionAllCommandConfirmation, + "ArrowSync", + IconVariant.Regular, + IsHighlighted: true), + new( + AzureEnvironmentCommand.DeleteAzureResources, + DeleteAzureResourcesCommandName, + AzureProvisioningStrings.DeleteAzureResourcesCommandName, + AzureProvisioningStrings.DeleteAzureResourcesCommandDescription, + AzureProvisioningStrings.DeleteAzureResourcesCommandConfirmation, + "Delete", + IconVariant.Regular, + IsHighlighted: true) + ]; + + internal static ImmutableArray ResourceCommandDefinitions { get; } = + [ + new( + AzureResourceCommand.ChangeLocation, + ChangeResourceLocationCommandName, + AzureProvisioningStrings.ChangeResourceLocationCommandName, + AzureProvisioningStrings.ChangeResourceLocationCommandDescription, + ConfirmationMessage: null, + "Location", + IconVariant.Regular, + IsHighlighted: false), + new( + AzureResourceCommand.ForgetState, + ForgetStateCommandName, + AzureProvisioningStrings.ForgetStateCommandName, + AzureProvisioningStrings.ForgetStateCommandDescription, + AzureProvisioningStrings.ForgetStateCommandConfirmation, + "ArrowReset", + IconVariant.Regular, + IsHighlighted: false), + new( + AzureResourceCommand.Reprovision, + ReprovisionResourceCommandName, + AzureProvisioningStrings.ReprovisionResourceCommandName, + AzureProvisioningStrings.ReprovisionResourceCommandDescription, + AzureProvisioningStrings.ReprovisionResourceCommandConfirmation, + "ArrowSync", + IconVariant.Regular, + IsHighlighted: true) + ]; + + public async Task ResetStateAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + await RunOperationAsync(model, new ResetStateIntent(), 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 ChangeAzureContextAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + return await RunOperationAsync(model, new ChangeAzureContextIntent(), cancellationToken).ConfigureAwait(false); + } + + public Task EnsureProvisionedAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + return RunOperationAsync(model, new EnsureProvisionedIntent(), cancellationToken); + } + + public async Task ReprovisionAllAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + return await RunOperationAsync(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) + { + if (_state.Status.CurrentIntent is not null || _driftCheckQueued) + { + return; + } + + _driftCheckQueued = true; + } + + try + { + await EnqueueOperationAsync(model, new DetectDriftIntent(), cancellationToken).ConfigureAwait(false); + } + catch + { + lock (_operationStateLock) + { + _driftCheckQueued = false; + } + + throw; + } + } + + public async Task ReprovisionResourceAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + return await RunOperationAsync(model, new ReprovisionResourceIntent(resourceName), cancellationToken).ConfigureAwait(false); + } + + public async Task ChangeResourceLocationAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + var interactionService = serviceProvider.GetRequiredService(); + if (!interactionService.IsAvailable) + { + throw new MissingConfigurationException("Azure resource location can't be changed because the interaction service is unavailable."); + } + + var currentLocation = await GetEffectiveResourceLocationAsync(resourceName, 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 + } + ], + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.Canceled) + { + return false; + } + + var location = result.Data[AzureBicepResource.KnownParameters.Location].Value; + if (string.IsNullOrWhiteSpace(location)) + { + return false; + } + + location = NormalizeLocation(location, locationOptions); + + return await RunOperationAsync(model, new ChangeResourceLocationIntent(resourceName, location), cancellationToken).ConfigureAwait(false); + } + + internal Task ExecuteEnvironmentCommandAsync(AzureEnvironmentCommand command, ExecuteCommandContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var model = context.ServiceProvider.GetRequiredService(); + + return command switch + { + AzureEnvironmentCommand.ResetProvisioningState => ExecuteCommandAsync( + () => ResetStateAsync(model, context.CancellationToken), + AzureProvisioningStrings.ResetProvisioningStateCommandSuccess), + AzureEnvironmentCommand.ChangeAzureContext => ExecuteCommandAsync( + () => ChangeAzureContextAsync(model, context.CancellationToken), + AzureProvisioningStrings.ChangeAzureContextCommandSuccess), + AzureEnvironmentCommand.ReprovisionAll => ExecuteCommandAsync( + () => ReprovisionAllAsync(model, context.CancellationToken), + AzureProvisioningStrings.ReprovisionAllCommandSuccess), + AzureEnvironmentCommand.DeleteAzureResources => ExecuteCommandAsync( + () => DeleteAzureResourcesAsync(model, context.CancellationToken), + AzureProvisioningStrings.DeleteAzureResourcesCommandSuccess), + _ => throw new ArgumentOutOfRangeException(nameof(command)) + }; + } + + internal Task ExecuteResourceCommandAsync(AzureResourceCommand command, string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.ServiceProvider.GetRequiredService(); + + return command switch + { + AzureResourceCommand.ChangeLocation => ExecuteCommandAsync( + () => ChangeResourceLocationAsync(model, resourceName, context.CancellationToken), + AzureProvisioningStrings.ChangeResourceLocationCommandSuccess), + AzureResourceCommand.ForgetState => ExecuteCommandAsync( + () => ForgetResourceStateAsync(model, resourceName, context.CancellationToken), + AzureProvisioningStrings.ForgetStateCommandSuccess), + AzureResourceCommand.Reprovision => ExecuteCommandAsync( + () => ReprovisionResourceAsync(model, resourceName, context.CancellationToken), + AzureProvisioningStrings.ReprovisionResourceCommandSuccess), + _ => throw new ArgumentOutOfRangeException(nameof(command)) + }; + } + + internal ResourceCommandState GetEnvironmentCommandState() + { + lock (_operationStateLock) + { + return _state.Status.CurrentIntent is null ? ResourceCommandState.Enabled : ResourceCommandState.Disabled; + } + } + + internal ResourceCommandState GetResourceCommandState(string resourceName) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + lock (_operationStateLock) + { + var currentOperation = _state.Status.CurrentIntent?.Operation; + if (currentOperation is null) + { + return ResourceCommandState.Enabled; + } + + return currentOperation.IsAllResources || currentOperation.ResourceNames.Contains(resourceName) + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled; + } + } + + private async Task RunOperationAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken) + { + _ = await EnqueueOperationAsync( + model, + intent, + cancellationToken).ConfigureAwait(false); + } + + private async Task RunOperationAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken) + { + return (T)(await EnqueueOperationAsync( + model, + intent, + cancellationToken).ConfigureAwait(false))!; + } + + private async Task EnsureProvisionedCoreAsync( + DistributedApplicationModel model, + IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources, + CancellationToken cancellationToken) + { + if (azureResources.Count == 0) + { + return true; + } + + await PublishAzureEnvironmentStateAsync( + model, + new ResourceStateSnapshot("Starting", KnownResourceStateStyles.Info), + cancellationToken).ConfigureAwait(false); + + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + var afterProvisionTasks = new List(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. + resource.AzureResource.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Starting", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + + afterProvisionTasks.Add(AfterProvisionAsync(resource, parentChildLookup)); + } + + await ProvisionAzureResourcesAsync(azureResources, parentChildLookup, cancellationToken).ConfigureAwait(false); + await Task.WhenAll(afterProvisionTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + + var hasFailures = azureResources.Any(static resource => + resource.AzureResource.ProvisioningTaskCompletionSource?.Task.IsFaulted == true); + + await PublishAzureEnvironmentStateAsync( + model, + hasFailures + ? new ResourceStateSnapshot("Failed to Provision", KnownResourceStateStyles.Error) + : new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success), + cancellationToken).ConfigureAwait(false); + + return !hasFailures; + } + + private async Task ResetResourcesAsync( + DistributedApplicationModel model, + IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> azureResources, + bool preserveOverrides, + CancellationToken cancellationToken) + { + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + 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 + ? TryGetCurrentResourceLocationOverride(bicepResource, environmentLocation) + : null; + + if (currentLocationOverride is not null) + { + bicepResource.Parameters[AzureBicepResource.KnownParameters.Location] = currentLocationOverride; + } + + await ClearCachedDeploymentStateAsync(bicepResource, preserveOverrides, environmentLocation, currentLocationOverride, cancellationToken).ConfigureAwait(false); + + bicepResource.Outputs.Clear(); + bicepResource.SecretOutputs.Clear(); + + if (bicepResource is IAzureKeyVaultResource keyVaultResource) + { + 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); + section.Data.Clear(); + await deploymentStateManager.DeleteSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + + private static List<(IResource Resource, IAzureResource AzureResource)> GetProvisionableAzureResources(DistributedApplicationModel model) + { + 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) + { + var azureResources = GetProvisionableAzureResources(model); + var targetResource = azureResources.SingleOrDefault(resource => + string.Equals(resource.Resource.Name, resourceName, StringComparison.Ordinal) || + string.Equals(resource.AzureResource.Name, resourceName, StringComparison.Ordinal)); + + if (targetResource == default) + { + throw new InvalidOperationException($"Azure resource '{resourceName}' was not found or cannot be reprovisioned."); + } + + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + var visitedResources = new HashSet(StringComparer.Ordinal); + var queue = new Queue<(IResource Resource, IAzureResource AzureResource)>(); + var targetResources = new List<(IResource Resource, IAzureResource AzureResource)>(); + + Enqueue(targetResource); + + 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(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 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 async Task EnqueueOperationAsync( + DistributedApplicationModel model, + AzureIntent intent, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureDriftMonitorStarted(model); + EnsureOperationLoopStarted(); + + var queuedOperation = new QueuedOperation( + model, + intent, + new(TaskCreationOptions.RunContinuationsAsynchronously), + cancellationToken); + + await _operationChannel.Writer.WriteAsync(queuedOperation, cancellationToken).ConfigureAwait(false); + return await queuedOperation.Completion.Task.ConfigureAwait(false); + } + + private void EnsureDriftMonitorStarted(DistributedApplicationModel model) + { + if (Interlocked.CompareExchange(ref _driftMonitorStarted, 1, 0) != 0) + { + return; + } + + var stoppingToken = serviceProvider.GetService()?.ApplicationStopping ?? CancellationToken.None; + var timeProvider = serviceProvider.GetService() ?? TimeProvider.System; + + _ = Task.Run(async () => + { + try + { + using var periodic = new PeriodicTimer(DriftCheckInterval, timeProvider); + + while (await periodic.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try + { + await CheckForDriftAsync(model, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Azure drift check failed."); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + }, stoppingToken); + } + + private void EnsureOperationLoopStarted() + { + if (Interlocked.CompareExchange(ref _operationLoopStarted, 1, 0) == 0) + { + _ = Task.Run(ProcessOperationLoopAsync); + } + } + + private async Task ProcessOperationLoopAsync() + { + await foreach (var operation in _operationChannel.Reader.ReadAllAsync().ConfigureAwait(false)) + { + if (operation.CancellationToken.IsCancellationRequested) + { + if (operation.Intent is DetectDriftIntent) + { + CompleteDriftCheck(); + } + + operation.Completion.TrySetCanceled(operation.CancellationToken); + continue; + } + + await ProcessQueuedOperationAsync(operation).ConfigureAwait(false); + } + } + + private async Task ProcessQueuedOperationAsync(QueuedOperation queuedOperation) + { + var updatesCommandState = queuedOperation.Intent is not DetectDriftIntent; + if (updatesCommandState) + { + StartOperation(queuedOperation.Intent); + await RefreshCommandStatesAsync(queuedOperation.Model, queuedOperation.CancellationToken).ConfigureAwait(false); + } + + try + { + var result = await ExecuteIntentAsync(queuedOperation.Model, queuedOperation.Intent, queuedOperation.CancellationToken).ConfigureAwait(false); + queuedOperation.Completion.TrySetResult(result); + } + catch (OperationCanceledException ex) when (queuedOperation.CancellationToken.IsCancellationRequested || ex.CancellationToken == queuedOperation.CancellationToken) + { + queuedOperation.Completion.TrySetCanceled(queuedOperation.CancellationToken.IsCancellationRequested ? queuedOperation.CancellationToken : ex.CancellationToken); + } + catch (Exception ex) + { + queuedOperation.Completion.TrySetException(ex); + } + finally + { + if (updatesCommandState) + { + CompleteOperation(queuedOperation.Intent); + await RefreshCommandStatesAsync(queuedOperation.Model, CancellationToken.None).ConfigureAwait(false); + } + else + { + CompleteDriftCheck(); + } + } + } + + private async Task ExecuteIntentAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken) + { + return intent switch + { + ResetStateIntent => await ExecuteResetStateAsync(model, cancellationToken).ConfigureAwait(false), + ForgetResourceStateIntent forgetResourceState => await ExecuteForgetResourceStateAsync(model, forgetResourceState, cancellationToken).ConfigureAwait(false), + ChangeAzureContextIntent => await ExecuteChangeAzureContextAsync(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), + DetectDriftIntent => await ExecuteDetectDriftAsync(model, cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentOutOfRangeException(nameof(intent)) + }; + } + + private async Task ExecuteResetStateAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + 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); + return null; + } + + private async Task ExecuteForgetResourceStateAsync(DistributedApplicationModel model, ForgetResourceStateIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + 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 ExecuteChangeAzureContextAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + var updated = await provisioningOptionsManager.EnsureProvisioningOptionsAsync(forcePrompt: true, cancellationToken).ConfigureAwait(false); + if (!updated) + { + return false; + } + + await provisioningOptionsManager.PersistProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); + await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken).ConfigureAwait(false); + await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false); + return await EnsureProvisionedCoreAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteEnsureProvisionedAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + var azureResources = GetProvisionableAzureResources(model); + await EnsureProvisionedCoreAsync(model, azureResources, cancellationToken).ConfigureAwait(false); + return null; + } + + private async Task ExecuteReprovisionAllAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken).ConfigureAwait(false); + await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false); + return await EnsureProvisionedCoreAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteDeleteAzureResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + await PublishAzureEnvironmentStateAsync( + model, + new ResourceStateSnapshot("Deleting", KnownResourceStateStyles.Info), + cancellationToken).ConfigureAwait(false); + + var provisioningContext = await provisioningContextProvider.CreateProvisioningContextAsync(cancellationToken).ConfigureAwait(false); + await provisioningContext.ResourceGroup.DeleteAsync(WaitUntil.Completed, cancellationToken).ConfigureAwait(false); + + await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken).ConfigureAwait(false); + await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Deleted Azure resource group {ResourceGroup}.", provisioningContext.ResourceGroup.Name); + return null; + } + + private async Task ExecuteChangeResourceLocationAsync(DistributedApplicationModel model, ChangeResourceLocationIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + if (targetResources[0].AzureResource is AzureBicepResource targetBicepResource) + { + await DeleteCachedResourceForLocationChangeAsync(targetBicepResource, intent.Location, cancellationToken).ConfigureAwait(false); + } + + await SetResourceLocationOverrideAsync(intent.ResourceName, intent.Location, cancellationToken).ConfigureAwait(false); + await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false); + return await EnsureProvisionedCoreAsync(model, targetResources, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteReprovisionResourceAsync(DistributedApplicationModel model, ReprovisionResourceIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false); + return await EnsureProvisionedCoreAsync(model, targetResources, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteDetectDriftAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + if (model.Resources.OfType().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 _)) + { + return null; + } + + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId); + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + List? driftedResources = null; + + foreach (var resource in GetProvisionableAzureResources(model)) + { + if (!ShouldCheckForDrift(resource.Resource) || + await TryGetResourceIdFromDeploymentStateAsync((AzureBicepResource)resource.AzureResource, cancellationToken).ConfigureAwait(false) is not { } resourceId) + { + 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 void StartOperation(AzureIntent intent) + { + lock (_operationStateLock) + { + _state = CreateControllerState(intent); + } + } + + private void CompleteOperation(AzureIntent intent) + { + lock (_operationStateLock) + { + if (ReferenceEquals(_state.Status.CurrentIntent, intent)) + { + _state = CreateControllerState(currentIntent: null); + } + } + } + + private void CompleteDriftCheck() + { + lock (_operationStateLock) + { + _driftCheckQueued = false; + } + } + + private AzureControllerState CreateControllerState(AzureIntent? currentIntent) + => new(CreateControllerSpec(), new AzureControllerStatus(currentIntent)); + + private AzureControllerSpec CreateControllerSpec() + { + var options = provisionerOptions.Value; + return new(options.SubscriptionId, options.ResourceGroup, options.Location, options.TenantId); + } + + private static async Task ExecuteCommandAsync(Func action, string successMessage) + { + try + { + await action().ConfigureAwait(false); + return CommandResults.Success(successMessage); + } + catch (Exception ex) + { + return CommandResults.Failure(ex); + } + } + + private static async Task ExecuteCommandAsync(Func> action, string successMessage) + { + try + { + return await action().ConfigureAwait(false) + ? CommandResults.Success(successMessage) + : CommandResults.Canceled(); + } + catch (Exception ex) + { + return CommandResults.Failure(ex); + } + } + + 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() is { Length: > 0 } locationOverride) + { + 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 GetEffectiveResourceLocationAsync(string resourceName, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resourceName}", cancellationToken).ConfigureAwait(false); + if (section.Data[LocationOverrideKey]?.GetValue() is { Length: > 0 } locationOverride) + { + 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); + } + + private string? TryGetCurrentResourceLocationOverride(AzureBicepResource resource, string? environmentLocation) + { + var currentLocationValue = TryGetCurrentResourceLocation(resource); + if (!string.IsNullOrWhiteSpace(currentLocationValue) && + (string.IsNullOrWhiteSpace(environmentLocation) || + !string.Equals(currentLocationValue, environmentLocation, StringComparison.OrdinalIgnoreCase))) + { + 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, StringComparison.OrdinalIgnoreCase))) + { + 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 (section.Data[LocationOverrideKey]?.GetValue() is { Length: > 0 } locationOverride) + { + return locationOverride; + } + + if (section.Data["Parameters"]?.GetValue() is not { Length: > 0 } parametersJson) + { + return null; + } + + try + { + var persistedLocation = JsonNode.Parse(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue(); + if (string.IsNullOrWhiteSpace(persistedLocation)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(environmentLocation) || + !string.Equals(persistedLocation, environmentLocation, StringComparison.OrdinalIgnoreCase)) + { + 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 NormalizeLocation(string location, IReadOnlyList> locationOptions) + { + if (string.IsNullOrWhiteSpace(location)) + { + return location; + } + + foreach (var option in locationOptions) + { + if (string.Equals(option.Key, location, StringComparison.OrdinalIgnoreCase) || + string.Equals(option.Value, location, StringComparison.OrdinalIgnoreCase)) + { + return option.Key; + } + } + + var canonicalLocation = CanonicalizeLocation(location); + if (!string.Equals(canonicalLocation, location, StringComparison.Ordinal)) + { + return canonicalLocation; + } + + return location; + } + + private static string CanonicalizeLocation(string location) + { + Span 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>> GetLocationOptionsAsync(CancellationToken cancellationToken) + { + var subscriptionId = (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).SubscriptionId; + if (!Guid.TryParse(subscriptionId, out _)) + { + return GetStaticLocationOptions(); + } + + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + 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) + { + _logger.LogWarning(ex, "Failed to enumerate Azure locations for resource override."); + return GetStaticLocationOptions(); + } + } + + private static IReadOnlyList> 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 GetCurrentAzureContextAsync(CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); + + return new AzureContextState( + section.Data["SubscriptionId"]?.GetValue() ?? provisionerOptions.Value.SubscriptionId ?? configuration["Azure:SubscriptionId"], + section.Data["ResourceGroup"]?.GetValue() ?? provisionerOptions.Value.ResourceGroup ?? configuration["Azure:ResourceGroup"], + section.Data["Location"]?.GetValue() ?? provisionerOptions.Value.Location ?? configuration["Azure:Location"], + section.Data["TenantId"]?.GetValue() ?? provisionerOptions.Value.TenantId ?? configuration["Azure:TenantId"]); + } + + private bool ShouldCheckForDrift(IResource resource) + { + if (!notificationService.TryGetCurrentState(resource.Name, out var resourceEvent)) + { + return false; + } + + return resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running; + } + + private async Task TryGetResourceIdFromDeploymentStateAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + if (section.Data["Outputs"]?.GetValue() is not { Length: > 0 } outputsJson) + { + return null; + } + + try + { + return JsonNode.Parse(outputsJson)?["id"]?["value"]?.GetValue(); + } + 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 DeleteCachedResourceForLocationChangeAsync(AzureBicepResource resource, string requestedLocation, CancellationToken cancellationToken) + { + var currentLocation = TryGetCurrentResourceLocation(resource); + if (string.IsNullOrWhiteSpace(currentLocation) || + string.Equals(currentLocation, requestedLocation, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (await TryGetResourceIdFromDeploymentStateAsync(resource, cancellationToken).ConfigureAwait(false) is not { } resourceId) + { + return; + } + + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(context.SubscriptionId, out _)) + { + return; + } + + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId); + if (!await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false)) + { + return; + } + + _logger.LogInformation( + "Deleting Azure resource {ResourceId} before reprovisioning {ResourceName} from {CurrentLocation} to {RequestedLocation}.", + resourceId, + resource.Name, + currentLocation, + requestedLocation); + + await armClient.DeleteResourceAsync(resourceId, cancellationToken).ConfigureAwait(false); + } + + private async Task IsMissingCachedResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + if (await TryGetResourceIdFromDeploymentStateAsync(resource, cancellationToken).ConfigureAwait(false) is not { } resourceId) + { + return false; + } + + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(context.SubscriptionId, out _)) + { + return false; + } + + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId); + return !await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false); + } + + private async Task ClearCachedDeploymentStateAsync( + AzureBicepResource resource, + bool preserveOverrides, + string? environmentLocation, + string? currentLocationOverride, + CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + var locationOverride = preserveOverrides + ? currentLocationOverride ?? TryGetPreservedLocationOverride(resource, section, environmentLocation) + : null; + + section.Data.Clear(); + if (locationOverride is not null) + { + section.Data[LocationOverrideKey] = locationOverride; + await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + else + { + await deploymentStateManager.DeleteSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RefreshCommandStatesAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var resource in GetResourcesForCommandStateRefresh(model)) + { + await notificationService.PublishUpdateAsync(resource, static state => state).ConfigureAwait(false); + } + } + + private static IEnumerable GetResourcesForCommandStateRefresh(DistributedApplicationModel model) + { + var seenNames = new HashSet(StringComparer.Ordinal); + var resources = new List(); + + if (model.Resources.OfType().SingleOrDefault() is { } environmentResource) + { + Add(environmentResource); + } + + foreach (var (resource, azureResource) in GetProvisionableAzureResources(model)) + { + 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 parentChildLookup, + Func stateFactory) + { + async Task PublishAsync(IResource targetResource) + { + await notificationService.PublishUpdateAsync(targetResource, stateFactory).ConfigureAwait(false); + } + + 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)) + { + childResources.Add(grandChild); + } + } + + await PublishAsync(child).ConfigureAwait(false); + } + } + + private async Task AfterProvisionAsync( + (IResource Resource, IAzureResource AzureResource) resource, + ILookup parentChildLookup) + { + try + { + await resource.AzureResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); + + var rolesFailed = await WaitForRoleAssignmentsAsync(resource, parentChildLookup).ConfigureAwait(false); + if (!rolesFailed) + { + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Running", KnownResourceStateStyles.Success) + }).ConfigureAwait(false); + } + } + catch (MissingConfigurationException) + { + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Missing subscription configuration", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + catch (Exception) + { + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Failed to Provision", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + } + + private async Task WaitForRoleAssignmentsAsync( + (IResource Resource, IAzureResource AzureResource) resource, + ILookup parentChildLookup) + { + var rolesFailed = false; + if (resource.AzureResource.TryGetAnnotationsOfType(out var roleAssignments)) + { + try + { + foreach (var roleAssignment in roleAssignments) + { + 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 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. + var provisioningContextLazy = new Lazy>(() => provisioningContextProvider.CreateProvisioningContextAsync(cancellationToken)); + var tasks = new List(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> provisioningContextLazy, + (IResource Resource, IAzureResource AzureResource) resource, + ILookup 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 beforeResourceStartedEvent = new BeforeResourceStartedEvent(resource.Resource, serviceProvider); + await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false); + + var resourceLogger = loggerService.GetLogger(resource.AzureResource); + + if (resource.AzureResource is not AzureBicepResource bicepResource) + { + CompleteProvisioning(resource.AzureResource); + resourceLogger.LogInformation("Skipping {resourceName} because it is not a Bicep resource.", resource.AzureResource.Name); + return; + } + + if (bicepResource.IsContainer() || bicepResource.IsEmulator()) + { + CompleteProvisioning(resource.AzureResource); + resourceLogger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.AzureResource.Name); + } + else + { + await WaitForProvisioningDependenciesAsync(bicepResource, cancellationToken).ConfigureAwait(false); + if (await IsMissingCachedResourceAsync(bicepResource, cancellationToken).ConfigureAwait(false)) + { + 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, + cancellationToken).ConfigureAwait(false); + } + + if (await bicepProvisioner.ConfigureResourceAsync(configuration, bicepResource, cancellationToken).ConfigureAwait(false)) + { + 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); + } + + try + { + var provisioningContext = await provisioningContextLazy.Value.ConfigureAwait(false); + + 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 (Exception ex) + { + resourceLogger.LogError(ex, "Error provisioning {ResourceName}.", resource.AzureResource.Name); + FailProvisioning(resource.AzureResource, new InvalidOperationException($"Unable to resolve references from {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 async Task WaitForProvisioningDependenciesAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + _ = resource.GetBicepTemplateString(); + + var dependencies = new HashSet(); + + foreach (var parameter in resource.Parameters.Values) + { + CollectProvisioningDependencies(dependencies, parameter); + } + + foreach (var reference in resource.References) + { + CollectProvisioningDependencies(dependencies, reference); + } + + foreach (var dependency in dependencies) + { + if (ReferenceEquals(dependency, resource)) + { + continue; + } + + if (dependency.ProvisioningTaskCompletionSource is { } provisioning) + { + await provisioning.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private static void CollectProvisioningDependencies(HashSet dependencies, object? value) + { + CollectProvisioningDependencies(dependencies, value, []); + } + + private static void CollectProvisioningDependencies(HashSet dependencies, object? value, HashSet visited) + { + if (value is null || !visited.Add(value)) + { + 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 parentChildLookup, + CancellationToken cancellationToken) + { + if (targetResource is IResourceWithConnectionString) + { + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(targetResource, serviceProvider); + await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); + } + + if (parentChildLookup[targetResource] is { } children) + { + foreach (var child in children.OfType().Where(static c => c is IResourceWithParent)) + { + await PublishConnectionStringAvailableEventAsync(child, parentChildLookup, cancellationToken).ConfigureAwait(false); + } + } + } + + private static ImmutableArray FilterProperties(ImmutableArray properties) + { + if (properties.IsDefaultOrEmpty) + { + return []; + } + + return [.. properties.Where(static property => !s_resettableProperties.Contains(property.Name, StringComparer.Ordinal))]; + } + + 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 _) + { + if (model.Resources.OfType().SingleOrDefault() is not { } azureEnvironmentResource) + { + return; + } + + await notificationService.PublishUpdateAsync(azureEnvironmentResource, existingState => existingState with + { + State = state, + Properties = state.Text == KnownResourceStates.NotStarted + ? FilterProperties(existingState.Properties) + : FilterProperties(existingState.Properties).SetResourcePropertyRange(BuildAzureEnvironmentProperties(ReadControllerState().Spec)), + 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 AzureControllerState ReadControllerState() + { + lock (_operationStateLock) + { + return _state; + } + } + + private static ImmutableArray BuildAzureEnvironmentProperties(AzureControllerSpec spec) + { + var properties = ImmutableArray.Empty; + + if (!string.IsNullOrEmpty(spec.SubscriptionId)) + { + properties = properties.SetResourceProperty("azure.subscription.id", spec.SubscriptionId); + } + + if (!string.IsNullOrEmpty(spec.ResourceGroup)) + { + properties = properties.SetResourceProperty("azure.resource.group", spec.ResourceGroup); + } + + if (!string.IsNullOrEmpty(spec.Location)) + { + properties = properties.SetResourceProperty("azure.location", spec.Location); + } + + if (!string.IsNullOrEmpty(spec.TenantId)) + { + properties = properties.SetResourceProperty("azure.tenant.id", spec.TenantId); + } + + return properties; + } + + private sealed class AzureOperationState(string displayName, bool isAllResources, IReadOnlySet resourceNames) + { + public string DisplayName { get; } = displayName; + public bool IsAllResources { get; } = isAllResources; + public IReadOnlySet ResourceNames { get; } = resourceNames; + + public static AzureOperationState None { get; } = new(string.Empty, false, new HashSet(StringComparer.Ordinal)); + + public static AzureOperationState All(string displayName) => new(displayName, true, new HashSet(StringComparer.Ordinal)); + + public static AzureOperationState Resource(string resourceName, string displayName) => new(displayName, false, new HashSet([resourceName], StringComparer.Ordinal)); + } + + private sealed record AzureControllerState(AzureControllerSpec Spec, AzureControllerStatus Status) + { + public static AzureControllerState Empty { get; } = new(new(null, null, null, null), new(null)); + } + + private sealed record AzureControllerSpec(string? SubscriptionId, string? ResourceGroup, string? Location, string? TenantId); + + private sealed record AzureControllerStatus(AzureIntent? CurrentIntent); + + internal enum AzureEnvironmentCommand + { + ResetProvisioningState, + ChangeAzureContext, + ReprovisionAll, + DeleteAzureResources + } + + internal enum AzureResourceCommand + { + ChangeLocation, + ForgetState, + Reprovision + } + + internal sealed record EnvironmentCommandDefinition( + AzureEnvironmentCommand Command, + string Name, + string DisplayName, + string Description, + string ConfirmationMessage, + string IconName, + IconVariant IconVariant, + bool IsHighlighted); + + internal sealed record ResourceCommandDefinition( + AzureResourceCommand Command, + string Name, + string DisplayName, + string Description, + string? ConfirmationMessage, + string IconName, + IconVariant IconVariant, + bool IsHighlighted); + + private abstract record AzureIntent(AzureOperationState Operation); + + private sealed record ResetStateIntent() : AzureIntent(AzureOperationState.All("Reset provisioning state")); + + private sealed record ChangeAzureContextIntent() : AzureIntent(AzureOperationState.All("Change 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) : AzureIntent(AzureOperationState.Resource(ResourceName, "Change Azure resource location")); + + private sealed record ReprovisionResourceIntent(string ResourceName) : AzureIntent(AzureOperationState.Resource(ResourceName, "Reprovision Azure resource")); + + private sealed record DetectDriftIntent() : AzureIntent(AzureOperationState.None); + + private sealed record QueuedOperation( + DistributedApplicationModel Model, + AzureIntent Intent, + TaskCompletionSource Completion, + CancellationToken CancellationToken); + + private sealed record AzureContextState(string? SubscriptionId, string? ResourceGroup, string? Location, string? TenantId); +} diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index ca7d6d40d94..0ee8a726b41 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -9,6 +9,7 @@ using Aspire.Hosting.Lifecycle; using Azure.Provisioning; using Azure.Provisioning.Authorization; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure; @@ -39,6 +40,11 @@ public async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken await BuildRoleAssignmentAnnotations(@event.Model, azureResources, cancellationToken).ConfigureAwait(false); + if (executionContext.IsRunMode) + { + AddPerResourceCommands(azureResources); + } + // set the ProvisioningBuildOptions on the resource, if necessary foreach (var r in azureResources) { @@ -49,6 +55,62 @@ public async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken } } + private static void AddPerResourceCommands(List<(IResource Resource, IAzureResource AzureResource)> azureResources) + { + foreach (var resource in azureResources) + { + if (resource.AzureResource is not AzureBicepResource bicepResource || + bicepResource.IsContainer() || + bicepResource.IsEmulator()) + { + continue; + } + + foreach (var command in AzureProvisioningController.ResourceCommandDefinitions) + { + AddOrReplaceCommand( + resource.Resource, + command.Name, + command.DisplayName, + executeCommand: context => context.ServiceProvider.GetRequiredService().ExecuteResourceCommandAsync(command.Command, resource.Resource.Name, context), + new CommandOptions + { + Description = command.Description, + ConfirmationMessage = command.ConfirmationMessage, + IconName = command.IconName, + IconVariant = command.IconVariant, + IsHighlighted = command.IsHighlighted, + UpdateState = context => context.ServiceProvider.GetRequiredService().GetResourceCommandState(resource.Resource.Name) + }); + } + } + } + + private static void AddOrReplaceCommand( + IResource resource, + string name, + string displayName, + Func> executeCommand, + CommandOptions commandOptions) + { + if (resource.Annotations.OfType().SingleOrDefault(annotation => annotation.Name == name) is { } existingAnnotation) + { + resource.Annotations.Remove(existingAnnotation); + } + + resource.Annotations.Add(new ResourceCommandAnnotation( + name, + displayName, + commandOptions.UpdateState ?? (_ => ResourceCommandState.Enabled), + executeCommand, + commandOptions.Description, + commandOptions.Parameter, + commandOptions.ConfirmationMessage, + commandOptions.IconName, + commandOptions.IconVariant, + commandOptions.IsHighlighted)); + } + internal static List<(IResource Resource, IAzureResource AzureResource)> GetAzureResourcesFromAppModel(DistributedApplicationModel appModel) { // Some resources do not derive from IAzureResource but can be handled diff --git a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs index c2924ef1bc6..7d51a0a2330 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs @@ -65,8 +65,17 @@ public static IDistributedApplicationBuilder AddAzureProvisioning(this IDistribu } else { - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); } + + // The controller is registered unconditionally because AzureProvisioner (an eventing subscriber resolved + // in all modes) takes it as a constructor dependency. In publish mode, the controller's interactive + // features (change-context, change-location) are never invoked, but it must be resolvable. + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); return builder; diff --git a/src/Aspire.Hosting.Azure/Provisioning/BicepUtilities.cs b/src/Aspire.Hosting.Azure/Provisioning/BicepUtilities.cs index 38c43fea9ed..eb9329354f1 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/BicepUtilities.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/BicepUtilities.cs @@ -1,10 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable ASPIREPIPELINES002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + using System.IO.Hashing; using System.Text; using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; using Microsoft.Extensions.Configuration; namespace Aspire.Hosting.Azure.Provisioning; @@ -142,6 +145,44 @@ public static string GetChecksum(AzureBicepResource resource, JsonObject paramet } } + /// + /// Gets the current checksum for a Bicep resource from deployment state. + /// + public static async ValueTask GetCurrentChecksumAsync(AzureBicepResource resource, DeploymentStateSection section, CancellationToken cancellationToken = default) + { + if (section.Data["Parameters"]?.GetValue() is not { Length: > 0 } jsonString) + { + return null; + } + + try + { + var parameters = JsonNode.Parse(jsonString)?.AsObject(); + var scope = section.Data["Scope"]?.GetValue() is { Length: > 0 } scopeString + ? JsonNode.Parse(scopeString)?.AsObject() + : null; + + if (parameters is null) + { + return null; + } + + _ = resource.GetBicepTemplateString(); + + await SetParametersAsync(parameters, resource, skipKnownValues: true, cancellationToken: cancellationToken).ConfigureAwait(false); + if (scope is not null) + { + await SetScopeAsync(scope, resource, cancellationToken).ConfigureAwait(false); + } + + return GetChecksum(resource, parameters, scope); + } + catch + { + return null; + } + } + internal static object? GetExistingResourceGroup(AzureBicepResource resource) => resource.Scope?.ResourceGroup ?? (resource.TryGetLastAnnotation(out var existingResource) ? diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs index d7be67becf2..19d80688118 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs @@ -36,11 +36,21 @@ internal abstract partial class BaseProvisioningContextProvider( protected readonly IInteractionService _interactionService = interactionService; protected readonly AzureProvisionerOptions _options = options.Value; + protected readonly AzureProvisionerOptions _configuredOptions = new() + { + ResourceGroupPrefix = options.Value.ResourceGroupPrefix, + AllowResourceGroupCreation = options.Value.AllowResourceGroupCreation, + Location = options.Value.Location, + SubscriptionId = options.Value.SubscriptionId, + ResourceGroup = options.Value.ResourceGroup, + TenantId = options.Value.TenantId + }; protected readonly IHostEnvironment _environment = environment; protected readonly ILogger _logger = logger; protected readonly IArmClientProvider _armClientProvider = armClientProvider; protected readonly IUserPrincipalProvider _userPrincipalProvider = userPrincipalProvider; protected readonly ITokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; + protected readonly IDeploymentStateManager _deploymentStateManager = deploymentStateManager; protected readonly DistributedApplicationExecutionContext _distributedApplicationExecutionContext = distributedApplicationExecutionContext; [GeneratedRegex(@"^[a-zA-Z0-9_\-\.\(\)]+$")] @@ -99,7 +109,7 @@ public virtual async Task CreateProvisioningContextAsync(Ca } // Acquire Azure state section for reading/writing configuration - var azureStateSection = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); + var azureStateSection = await _deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); string resourceGroupName; bool createIfAbsent; @@ -169,35 +179,47 @@ public virtual async Task CreateProvisioningContextAsync(Ca var principal = await _userPrincipalProvider.GetUserPrincipalAsync(cancellationToken).ConfigureAwait(false); - // Persist the provisioning options to deployment state so they can be reused in the future + await SaveProvisioningOptionsAsync(resourceGroupName, azureStateSection, cancellationToken).ConfigureAwait(false); + + return new ProvisioningContext( + credential, + armClient, + subscriptionResource, + resourceGroup, + tenantResource, + location, + principal, + _distributedApplicationExecutionContext); + } + + protected abstract string GetDefaultResourceGroupName(); + + protected async Task SaveProvisioningOptionsAsync(string resourceGroupName, CancellationToken cancellationToken = default) + { + var azureStateSection = await _deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); + await SaveProvisioningOptionsAsync(resourceGroupName, azureStateSection, cancellationToken).ConfigureAwait(false); + } + + private async Task SaveProvisioningOptionsAsync(string resourceGroupName, DeploymentStateSection azureStateSection, CancellationToken cancellationToken) + { var azureSection = azureStateSection.Data; azureSection["Location"] = _options.Location; azureSection["SubscriptionId"] = _options.SubscriptionId; azureSection["ResourceGroup"] = resourceGroupName; + if (!string.IsNullOrEmpty(_options.TenantId)) { azureSection["TenantId"] = _options.TenantId; } + if (_options.AllowResourceGroupCreation.HasValue) { azureSection["AllowResourceGroupCreation"] = _options.AllowResourceGroupCreation.Value; } - await deploymentStateManager.SaveSectionAsync(azureStateSection, cancellationToken).ConfigureAwait(false); - - return new ProvisioningContext( - credential, - armClient, - subscriptionResource, - resourceGroup, - tenantResource, - location, - principal, - _distributedApplicationExecutionContext); + await _deploymentStateManager.SaveSectionAsync(azureStateSection, cancellationToken).ConfigureAwait(false); } - protected abstract string GetDefaultResourceGroupName(); - protected async Task<(List>? tenantOptions, bool fetchSucceeded)> TryGetTenantsAsync(CancellationToken cancellationToken) { List>? tenantOptions = null; diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/BicepCompiler.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/BicepCompiler.cs index 405cf5dbedd..d47b5f6a1fa 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/BicepCompiler.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/BicepCompiler.cs @@ -14,6 +14,7 @@ namespace Aspire.Hosting.Azure.Provisioning.Internal; internal sealed class BicepCliCompiler : IBicepCompiler { private readonly ILogger _logger; + private readonly SemaphoreSlim _compileLock = new(1, 1); public BicepCliCompiler(ILogger logger) { @@ -22,56 +23,84 @@ public BicepCliCompiler(ILogger logger) public async Task CompileBicepToArmAsync(string bicepFilePath, CancellationToken cancellationToken = default) { - // Try bicep command first for better performance - var bicepPath = PathLookupHelper.FindFullPathFromPath("bicep"); - string commandPath; - string arguments; + await _compileLock.WaitAsync(cancellationToken).ConfigureAwait(false); - if (bicepPath is not null) - { - commandPath = bicepPath; - arguments = $"build \"{bicepFilePath}\" --stdout"; - } - else + try { - // Fall back to az bicep if bicep command is not available - var azPath = PathLookupHelper.FindFullPathFromPath("az"); - if (azPath is null) + // Try bicep command first for better performance + var bicepPath = PathLookupHelper.FindFullPathFromPath("bicep"); + string commandPath; + string arguments; + + if (bicepPath is not null) { - throw new AzureCliNotOnPathException(); + commandPath = bicepPath; + arguments = $"build \"{bicepFilePath}\" --stdout"; + } + else + { + // Fall back to az bicep if bicep command is not available + var azPath = PathLookupHelper.FindFullPathFromPath("az"); + if (azPath is null) + { + throw new AzureCliNotOnPathException(); + } + commandPath = azPath; + arguments = $"bicep build --file \"{bicepFilePath}\" --stdout"; } - commandPath = azPath; - arguments = $"bicep build --file \"{bicepFilePath}\" --stdout"; - } - var armTemplateContents = new StringBuilder(); - var templateSpec = new ProcessSpec(commandPath) - { - Arguments = arguments, - OnOutputData = data => + var armTemplateContents = new StringBuilder(); + var errorContents = new StringBuilder(); + var templateSpec = new ProcessSpec(commandPath) { - _logger.LogDebug("{CommandPath} (stdout): {Output}", commandPath, data); - armTemplateContents.AppendLine(data); - }, - OnErrorData = data => + Arguments = arguments, + OnOutputData = data => + { + _logger.LogDebug("{CommandPath} (stdout): {Output}", commandPath, data); + armTemplateContents.AppendLine(data); + }, + OnErrorData = data => + { + _logger.LogDebug("{CommandPath} (stderr): {Error}", commandPath, data); + errorContents.AppendLine(data); + }, + }; + + _logger.LogDebug("Running {CommandPath} with arguments: {Arguments}", commandPath, arguments); + + int exitCode; + try { - _logger.LogDebug("{CommandPath} (stderr): {Error}", commandPath, data); - }, - }; + exitCode = await ExecuteCommand(templateSpec).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + var errorMessage = errorContents.ToString().Trim(); + throw new InvalidOperationException( + string.IsNullOrEmpty(errorMessage) + ? $"Failed to compile bicep file: {bicepFilePath}" + : $"Failed to compile bicep file: {bicepFilePath}{Environment.NewLine}{errorMessage}", + ex); + } - _logger.LogDebug("Running {CommandPath} with arguments: {Arguments}", commandPath, arguments); + if (exitCode != 0) + { + _logger.LogError("Bicep compilation for {BicepFilePath} failed with exit code {ExitCode}.", bicepFilePath, exitCode); + var errorMessage = errorContents.ToString().Trim(); + throw new InvalidOperationException( + string.IsNullOrEmpty(errorMessage) + ? $"Failed to compile bicep file: {bicepFilePath}" + : $"Failed to compile bicep file: {bicepFilePath}{Environment.NewLine}{errorMessage}"); + } - var exitCode = await ExecuteCommand(templateSpec).ConfigureAwait(false); + _logger.LogDebug("Bicep compilation for {BicepFilePath} succeeded.", bicepFilePath); - if (exitCode != 0) + return armTemplateContents.ToString(); + } + finally { - _logger.LogError("Bicep compilation for {BicepFilePath} failed with exit code {ExitCode}.", bicepFilePath, exitCode); - throw new InvalidOperationException($"Failed to compile bicep file: {bicepFilePath}"); + _compileLock.Release(); } - - _logger.LogDebug("Bicep compilation for {BicepFilePath} succeeded.", bicepFilePath); - - return armTemplateContents.ToString(); } private static async Task ExecuteCommand(ProcessSpec processSpec) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs index d7d6ed98705..e8bd9a7d2ef 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure; using Azure.Core; using Azure.ResourceManager; using Azure.ResourceManager.Resources; @@ -121,6 +122,26 @@ public async Task> GetAvailableSubscriptionsA return resourceGroups.OrderBy(rg => rg.Name); } + public async Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + { + try + { + var resource = armClient.GetGenericResource(new ResourceIdentifier(resourceId)); + await resource.GetAsync(cancellationToken).ConfigureAwait(false); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + public async Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + { + var resource = armClient.GetGenericResource(new ResourceIdentifier(resourceId)); + await resource.DeleteAsync(WaitUntil.Completed, cancellationToken).ConfigureAwait(false); + } + private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource { public Guid? TenantId => tenantResource.Data.TenantId; diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs index 5ec8d1f7e1c..a1e015913b6 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure; using Azure.Core; +using Azure.ResourceManager; using Azure.ResourceManager.Resources; namespace Aspire.Hosting.Azure.Provisioning.Internal; @@ -18,4 +20,7 @@ public IArmDeploymentCollection GetArmDeployments() { return new DefaultArmDeploymentCollection(resourceGroupResource.GetArmDeployments()); } + + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) => + resourceGroupResource.DeleteAsync(waitUntil, cancellationToken: cancellationToken); } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 40b0b2b714b..9c59e8a5673 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -61,6 +61,35 @@ internal interface IProvisioningContextProvider Task CreateProvisioningContextAsync(CancellationToken cancellationToken = default); } +/// +/// Provides interactive management of Azure provisioning options in run mode. +/// +internal interface IAzureProvisioningOptionsManager +{ + /// + /// Ensures Azure provisioning options are available, optionally forcing the user to re-enter them. + /// + /// Whether to force re-prompting even when options already exist. + /// The cancellation token. + /// true when options are available; otherwise, false if the interaction was canceled. + Task EnsureProvisioningOptionsAsync(bool forcePrompt, CancellationToken cancellationToken = default); + + /// + /// Persists the current provisioning options to deployment state without creating a provisioning context. + /// + /// The cancellation token. + Task PersistProvisioningOptionsAsync(CancellationToken cancellationToken = default); +} + +/// +/// No-op implementation used in publish mode where interactive provisioning options management is not needed. +/// +internal sealed class NoOpAzureProvisioningOptionsManager : IAzureProvisioningOptionsManager +{ + public Task EnsureProvisioningOptionsAsync(bool forcePrompt, CancellationToken cancellationToken = default) => Task.FromResult(false); + public Task PersistProvisioningOptionsAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; +} + /// /// Abstraction for Azure ArmClient. /// @@ -95,6 +124,16 @@ internal interface IArmClient /// Gets detailed information about available resource groups including their locations. /// Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default); + + /// + /// Determines whether the specified Azure resource currently exists. + /// + Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default); + + /// + /// Deletes the specified Azure resource. + /// + Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default); } /// @@ -163,6 +202,11 @@ internal interface IResourceGroupResource /// Gets ARM deployments collection. /// IArmDeploymentCollection GetArmDeployments(); + + /// + /// Deletes the resource group. + /// + Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default); } /// diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs index 5026f2233bb..e091cff688a 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs @@ -35,9 +35,9 @@ internal sealed class RunModeProvisioningContextProvider( userPrincipalProvider, tokenCredentialProvider, deploymentStateManager, - distributedApplicationExecutionContext) + distributedApplicationExecutionContext), IAzureProvisioningOptionsManager { - private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly SemaphoreSlim _provisioningOptionsLock = new(1, 1); protected override string GetDefaultResourceGroupName() { @@ -62,214 +62,155 @@ protected override string GetDefaultResourceGroupName() return $"{prefix}-{normalizedApplicationName}-{suffix}"; } - private void EnsureProvisioningOptions() + public async Task EnsureProvisioningOptionsAsync(bool forcePrompt, CancellationToken cancellationToken = default) { - if (!_interactionService.IsAvailable || - (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId))) + await RehydrateProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); + + if (!_interactionService.IsAvailable) { - // If the interaction service is not available, or - // if all options are already set, we can skip the prompt - _provisioningOptionsAvailable.TrySetResult(); - return; + if (forcePrompt) + { + throw new MissingConfigurationException("Azure provisioning options can't be changed because the interaction service is unavailable."); + } + + return HasProvisioningOptions(); } - // Start the loop that will allow the user to specify the Azure provisioning options - _ = Task.Run(async () => + if (!forcePrompt && HasProvisioningOptions()) { - try - { - await RetrieveAzureProvisioningOptions().ConfigureAwait(false); + return true; + } - _logger.LogDebug("Azure provisioning options have been handled successfully."); + await _provisioningOptionsLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + await RehydrateProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); + + if (!forcePrompt && HasProvisioningOptions()) + { + return true; } - catch (Exception ex) + + var result = await RetrieveAzureProvisioningOptionsAsync(forcePrompt, cancellationToken).ConfigureAwait(false); + if (result) { - _logger.LogError(ex, "Failed to retrieve Azure provisioning options."); - _provisioningOptionsAvailable.SetException(ex); + _logger.LogDebug("Azure provisioning options have been handled successfully."); } - }); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve Azure provisioning options."); + throw; + } + finally + { + _provisioningOptionsLock.Release(); + } } public override async Task CreateProvisioningContextAsync(CancellationToken cancellationToken = default) { - EnsureProvisioningOptions(); + await RehydrateProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); - await _provisioningOptionsAvailable.Task.ConfigureAwait(false); + var result = await EnsureProvisioningOptionsAsync(forcePrompt: false, cancellationToken).ConfigureAwait(false); + if (!result) + { + if (!_interactionService.IsAvailable) + { + return await base.CreateProvisioningContextAsync(cancellationToken).ConfigureAwait(false); + } + + throw new MissingConfigurationException("Azure provisioning options were not provided."); + } return await base.CreateProvisioningContextAsync(cancellationToken).ConfigureAwait(false); } - private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellationToken = default) + public async Task PersistProvisioningOptionsAsync(CancellationToken cancellationToken = default) { - while (_options.Location == null || _options.SubscriptionId == null) + if (string.IsNullOrEmpty(_options.ResourceGroup)) { - var messageBarResult = await _interactionService.PromptNotificationAsync( - AzureProvisioningStrings.NotificationTitle, - AzureProvisioningStrings.NotificationMessage, - new NotificationInteractionOptions - { - Intent = MessageIntent.Warning, - PrimaryButtonText = AzureProvisioningStrings.NotificationPrimaryButtonText - }, - cancellationToken) - .ConfigureAwait(false); - - if (messageBarResult.Canceled) - { - // User canceled the prompt, so we exit the loop - _provisioningOptionsAvailable.SetException(new MissingConfigurationException("Azure provisioning options were not provided.")); - return; - } + _options.ResourceGroup = GetDefaultResourceGroupName(); + _options.AllowResourceGroupCreation ??= true; + } - if (messageBarResult.Data) - { - var inputs = new List(); + await SaveProvisioningOptionsAsync(_options.ResourceGroup, cancellationToken).ConfigureAwait(false); + } - // Skip tenant prompting if subscription ID is already set - if (string.IsNullOrEmpty(_options.SubscriptionId)) - { - inputs.Add(new InteractionInput - { - Name = TenantName, - InputType = InputType.Choice, - Label = AzureProvisioningStrings.TenantLabel, - Required = true, - AllowCustomChoice = true, - Placeholder = AzureProvisioningStrings.TenantPlaceholder, - DynamicLoading = new InputLoadOptions - { - LoadCallback = async (context) => - { - var (tenantOptions, fetchSucceeded) = - await TryGetTenantsAsync(cancellationToken).ConfigureAwait(false); + private bool HasProvisioningOptions() => + !string.IsNullOrEmpty(_options.Location) && + !string.IsNullOrEmpty(_options.SubscriptionId); - context.Input.Options = fetchSucceeded - ? tenantOptions! - : []; - } - } - }); - } + private async Task RehydrateProvisioningOptionsAsync(CancellationToken cancellationToken) + { + var azureSection = await _deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); - // If the subscription ID is already set - // show the value as from the configuration and disable the input - // there should be no option to change it + _options.ResourceGroupPrefix = _configuredOptions.ResourceGroupPrefix; + _options.AllowResourceGroupCreation = _configuredOptions.AllowResourceGroupCreation; + _options.Location = _configuredOptions.Location; + _options.SubscriptionId = _configuredOptions.SubscriptionId; + _options.ResourceGroup = _configuredOptions.ResourceGroup; + _options.TenantId = _configuredOptions.TenantId; - InputLoadOptions? subscriptionLoadOptions = null; - InputType inputType = InputType.Text; - if (string.IsNullOrEmpty(_options.SubscriptionId)) - { - inputType = InputType.Choice; - subscriptionLoadOptions = new InputLoadOptions - { - LoadCallback = async (context) => - { - // Get tenant ID from input if tenant selection is enabled, otherwise use configured value - var tenantId = context.AllInputs[TenantName].Value ?? string.Empty; - - var (subscriptionOptions, fetchSucceeded) = - await TryGetSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false); - - context.Input.Options = fetchSucceeded - ? subscriptionOptions! - : []; - context.Input.Disabled = false; - }, - DependsOnInputs = [TenantName] - }; - } + var data = azureSection.Data; + if (data["Location"]?.GetValue() is { Length: > 0 } location) + { + _options.Location = location; + } - inputs.Add(new InteractionInput - { - Name = SubscriptionIdName, - InputType = inputType, - Label = AzureProvisioningStrings.SubscriptionIdLabel, - Required = true, - AllowCustomChoice = true, - Placeholder = AzureProvisioningStrings.SubscriptionIdPlaceholder, - Disabled = true, - Value = _options.SubscriptionId, - DynamicLoading = subscriptionLoadOptions - }); - - var defaultResourceGroupNameSet = false; - inputs.Add(new InteractionInput - { - Name = ResourceGroupName, - InputType = InputType.Choice, - Label = AzureProvisioningStrings.ResourceGroupLabel, - Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder, - AllowCustomChoice = true, - Disabled = true, - DynamicLoading = new InputLoadOptions - { - LoadCallback = async (context) => - { - var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty; + if (data["SubscriptionId"]?.GetValue() is { Length: > 0 } subscriptionId) + { + _options.SubscriptionId = subscriptionId; + } - var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false); + if (data["ResourceGroup"]?.GetValue() is { Length: > 0 } resourceGroup) + { + _options.ResourceGroup = resourceGroup; + } - if (fetchSucceeded && resourceGroupOptions is not null) - { - context.Input.Options = resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList(); - } - else - { - context.Input.Options = []; + if (data["TenantId"]?.GetValue() is { Length: > 0 } tenantId) + { + _options.TenantId = tenantId; + } - // Only default the resource group name if we couldn't fetch existing ones. - if (string.IsNullOrEmpty(context.Input.Value) && !defaultResourceGroupNameSet) - { - context.Input.Value = GetDefaultResourceGroupName(); - defaultResourceGroupNameSet = true; - } - } - context.Input.Disabled = false; - }, - DependsOnInputs = [SubscriptionIdName] - } - }); + if (data["AllowResourceGroupCreation"]?.GetValue() is bool allowResourceGroupCreation) + { + _options.AllowResourceGroupCreation = allowResourceGroupCreation; + } + } - inputs.Add(new InteractionInput + private async Task RetrieveAzureProvisioningOptionsAsync(bool forcePrompt, CancellationToken cancellationToken = default) + { + while (forcePrompt || _options.Location == null || _options.SubscriptionId == null) + { + var shouldPrompt = true; + if (!forcePrompt) + { + var messageBarResult = await _interactionService.PromptNotificationAsync( + AzureProvisioningStrings.NotificationTitle, + AzureProvisioningStrings.NotificationMessage, + new NotificationInteractionOptions + { + Intent = MessageIntent.Warning, + PrimaryButtonText = AzureProvisioningStrings.NotificationPrimaryButtonText + }, + cancellationToken) + .ConfigureAwait(false); + + if (messageBarResult.Canceled) { - Name = LocationName, - InputType = InputType.Choice, - Label = AzureProvisioningStrings.LocationLabel, - Placeholder = AzureProvisioningStrings.LocationPlaceholder, - Required = true, - Disabled = true, - DynamicLoading = new InputLoadOptions - { - LoadCallback = async (context) => - { - var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty; - var resourceGroupName = context.AllInputs[ResourceGroupName].Value ?? string.Empty; - - // Check if the selected resource group is an existing one - var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false); - - if (fetchSucceeded && resourceGroupOptions is not null) - { - var (_, resourceGroupLocation) = resourceGroupOptions.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase)); - if (!string.IsNullOrEmpty(resourceGroupLocation)) - { - // Use location from existing resource group - context.Input.Options = [KeyValuePair.Create(resourceGroupLocation, resourceGroupLocation)]; - context.Input.Value = resourceGroupLocation; - context.Input.Disabled = true; // Make it read-only since it's from existing RG - return; - } - } + return false; + } - // For new resource groups, load all locations - var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false); - context.Input.Options = locationOptions; - context.Input.Disabled = false; - }, - DependsOnInputs = [SubscriptionIdName, ResourceGroupName] - } - }); + shouldPrompt = messageBarResult.Data; + } + if (shouldPrompt) + { + var inputs = CreateProvisioningInputs(forcePrompt, cancellationToken); var result = await _interactionService.PromptInputsAsync( AzureProvisioningStrings.InputsTitle, AzureProvisioningStrings.InputsMessage, @@ -305,21 +246,187 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati }, cancellationToken).ConfigureAwait(false); - if (!result.Canceled) + if (result.Canceled) { - // Only set tenant ID if it was part of the input (when subscription ID wasn't already set) - if (result.Data.TryGetByName(TenantName, out var tenantInput)) + return false; + } + + ApplyProvisioningInputs(result.Data); + return true; + } + + if (!forcePrompt) + { + return false; + } + + forcePrompt = false; + } + + return true; + } + + private List CreateProvisioningInputs(bool forcePrompt, CancellationToken cancellationToken) + { + var inputs = new List(); + var includeTenantInput = forcePrompt || string.IsNullOrEmpty(_options.SubscriptionId); + + if (includeTenantInput) + { + inputs.Add(new InteractionInput + { + Name = TenantName, + InputType = InputType.Choice, + Label = AzureProvisioningStrings.TenantLabel, + Required = true, + AllowCustomChoice = true, + Placeholder = AzureProvisioningStrings.TenantPlaceholder, + Value = _options.TenantId, + DynamicLoading = new InputLoadOptions + { + AlwaysLoadOnStart = true, + LoadCallback = async (context) => { - _options.TenantId = tenantInput.Value; + var (tenantOptions, fetchSucceeded) = + await TryGetTenantsAsync(cancellationToken).ConfigureAwait(false); + + context.Input.Options = fetchSucceeded + ? tenantOptions! + : []; } - _options.Location = result.Data[LocationName].Value; - _options.SubscriptionId ??= result.Data[SubscriptionIdName].Value; - _options.ResourceGroup = result.Data[ResourceGroupName].Value; - _options.AllowResourceGroupCreation = true; // Allow the creation of the resource group if it does not exist. + } + }); + } + + var allowSubscriptionEdit = forcePrompt || string.IsNullOrEmpty(_options.SubscriptionId); + inputs.Add(new InteractionInput + { + Name = SubscriptionIdName, + InputType = allowSubscriptionEdit ? InputType.Choice : InputType.Text, + Label = AzureProvisioningStrings.SubscriptionIdLabel, + Required = true, + AllowCustomChoice = true, + Placeholder = AzureProvisioningStrings.SubscriptionIdPlaceholder, + Disabled = includeTenantInput || !allowSubscriptionEdit, + Value = _options.SubscriptionId, + DynamicLoading = allowSubscriptionEdit + ? new InputLoadOptions + { + AlwaysLoadOnStart = !includeTenantInput, + LoadCallback = async (context) => + { + var tenantId = includeTenantInput && context.AllInputs.TryGetByName(TenantName, out var tenantInput) + ? tenantInput.Value + : _options.TenantId; + + var (subscriptionOptions, fetchSucceeded) = + await TryGetSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false); + + context.Input.Options = fetchSucceeded + ? subscriptionOptions! + : []; + context.Input.Disabled = false; + }, + DependsOnInputs = includeTenantInput ? [TenantName] : [] + } + : null + }); + + var defaultResourceGroupNameSet = false; + var useTextResourceGroupInput = forcePrompt; + inputs.Add(new InteractionInput + { + Name = ResourceGroupName, + InputType = useTextResourceGroupInput ? InputType.Text : InputType.Choice, + Label = AzureProvisioningStrings.ResourceGroupLabel, + Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder, + AllowCustomChoice = !useTextResourceGroupInput, + Disabled = false, + Value = _options.ResourceGroup ?? (useTextResourceGroupInput ? GetDefaultResourceGroupName() : null), + DynamicLoading = useTextResourceGroupInput + ? null + : new InputLoadOptions + { + AlwaysLoadOnStart = true, + LoadCallback = async (context) => + { + var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty; + + var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false); + + if (fetchSucceeded && resourceGroupOptions is not null) + { + context.Input.Options = resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList(); + } + else + { + context.Input.Options = []; - _provisioningOptionsAvailable.SetResult(); + if (string.IsNullOrEmpty(context.Input.Value) && !defaultResourceGroupNameSet) + { + context.Input.Value = GetDefaultResourceGroupName(); + defaultResourceGroupNameSet = true; + } + } + + context.Input.Disabled = false; + }, + DependsOnInputs = [SubscriptionIdName] } + }); + + inputs.Add(new InteractionInput + { + Name = LocationName, + InputType = InputType.Choice, + Label = AzureProvisioningStrings.LocationLabel, + Placeholder = AzureProvisioningStrings.LocationPlaceholder, + Required = true, + Disabled = false, + Value = _options.Location, + DynamicLoading = new InputLoadOptions + { + AlwaysLoadOnStart = true, + LoadCallback = async (context) => + { + var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty; + var resourceGroupName = context.AllInputs[ResourceGroupName].Value ?? string.Empty; + + var (resourceGroupOptions, fetchSucceeded) = await TryGetResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false); + + if (fetchSucceeded && resourceGroupOptions is not null) + { + var (_, resourceGroupLocation) = resourceGroupOptions.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(resourceGroupLocation)) + { + context.Input.Options = [KeyValuePair.Create(resourceGroupLocation, resourceGroupLocation)]; + context.Input.Value = resourceGroupLocation; + context.Input.Disabled = true; + return; + } + } + + var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false); + context.Input.Options = locationOptions; + context.Input.Disabled = false; + }, + DependsOnInputs = [SubscriptionIdName, ResourceGroupName] } + }); + + return inputs; + } + + private void ApplyProvisioningInputs(InteractionInputCollection inputs) + { + if (inputs.TryGetByName(TenantName, out var tenantInput)) + { + _options.TenantId = tenantInput.Value; } + + _options.Location = inputs[LocationName].Value; + _options.SubscriptionId = inputs[SubscriptionIdName].Value; + _options.ResourceGroup = inputs[ResourceGroupName].Value; + _options.AllowResourceGroupCreation = true; } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs index 5eab0982dbf..9d55f68f0b5 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -4,277 +4,19 @@ #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 Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Azure.Provisioning; -using Aspire.Hosting.Azure.Provisioning.Internal; using Aspire.Hosting.Eventing; using Aspire.Hosting.Lifecycle; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Azure; // Provisions azure resources for development purposes internal sealed class AzureProvisioner( - IConfiguration configuration, - IServiceProvider serviceProvider, - IBicepProvisioner bicepProvisioner, - ResourceNotificationService notificationService, - ResourceLoggerService loggerService, - IDistributedApplicationEventing eventing, - IProvisioningContextProvider provisioningContextProvider - ) : IDistributedApplicationEventingSubscriber + AzureProvisioningController provisioningController) : IDistributedApplicationEventingSubscriber { - internal const string AspireResourceNameTag = "aspire-resource-name"; - - private ILookup? _parentChildLookup; - - private async Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken = default) + private Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken = default) { - var azureResources = AzureResourcePreparer.GetAzureResourcesFromAppModel(@event.Model); - if (azureResources.Count == 0) - { - return; - } - - // Create a map of parents to their children used to propagate state changes later. - _parentChildLookup = @event.Model.Resources.OfType().ToLookup(r => r.Parent); - - // Sets the state of the resource and all of its children - async Task UpdateStateAsync((IResource Resource, IAzureResource AzureResource) resource, Func stateFactory) - { - await notificationService.PublishUpdateAsync(resource.AzureResource, stateFactory).ConfigureAwait(false); - - // Some IAzureResource instances are a surrogate for for another resource in the app model - // to ensure that resource events are published for the resource that the user expects - // we lookup the resource in the app model here and publish the update to it as well. - if (resource.Resource != resource.AzureResource) - { - await notificationService.PublishUpdateAsync(resource.Resource, stateFactory).ConfigureAwait(false); - } - - // We basically want child resources to be moved into the same state as their parent resources whenever - // there is a state update. This is done for us in DCP so we replicate the behavior here in the Azure Provisioner. - - var childResources = _parentChildLookup[resource.Resource].ToList(); - - for (var i = 0; i < childResources.Count; i++) - { - var child = childResources[i]; - - // Add any level of children - foreach (var grandChild in _parentChildLookup[child]) - { - if (!childResources.Contains(grandChild)) - { - childResources.Add(grandChild); - } - } - - await notificationService.PublishUpdateAsync(child, stateFactory).ConfigureAwait(false); - } - } - - // After the resource is provisioned, set its state - async Task AfterProvisionAsync((IResource Resource, IAzureResource AzureResource) resource) - { - try - { - await resource.AzureResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); - - var rolesFailed = await WaitForRoleAssignments(resource).ConfigureAwait(false); - if (!rolesFailed) - { - await UpdateStateAsync(resource, s => s with - { - State = new("Running", KnownResourceStateStyles.Success) - }) - .ConfigureAwait(false); - } - } - catch (MissingConfigurationException) - { - await UpdateStateAsync(resource, s => s with - { - State = new("Missing subscription configuration", KnownResourceStateStyles.Error) - }) - .ConfigureAwait(false); - } - catch (Exception) - { - await UpdateStateAsync(resource, s => s with - { - State = new("Failed to Provision", KnownResourceStateStyles.Error) - }) - .ConfigureAwait(false); - } - } - - async Task WaitForRoleAssignments((IResource Resource, IAzureResource AzureResource) resource) - { - var rolesFailed = false; - if (resource.AzureResource.TryGetAnnotationsOfType(out var roleAssignments)) - { - try - { - foreach (var roleAssignment in roleAssignments) - { - await roleAssignment.RolesResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); - } - } - catch (Exception) - { - rolesFailed = true; - await UpdateStateAsync(resource, s => s with - { - State = new("Failed to Provision Roles", KnownResourceStateStyles.Error) - }) - .ConfigureAwait(false); - } - } - - return rolesFailed; - } - - // Mark all resources as starting - foreach (var r in azureResources) - { - r.AzureResource!.ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); - - await UpdateStateAsync(r, s => s with - { - State = new("Starting", KnownResourceStateStyles.Info) - }) - .ConfigureAwait(false); - - // After the resource is provisioned, set its state - _ = AfterProvisionAsync(r); - } - - // This is fully async so we can just fire and forget - _ = Task.Run(() => ProvisionAzureResources( - configuration, - azureResources, - cancellationToken), cancellationToken); - } - - private async Task ProvisionAzureResources( - IConfiguration configuration, - IList<(IResource Resource, IAzureResource AzureResource)> azureResources, - CancellationToken cancellationToken) - { - // Make resources wait on the same provisioning context - var provisioningContextLazy = new Lazy>(() => provisioningContextProvider.CreateProvisioningContextAsync(cancellationToken)); - - var tasks = new List(); - - foreach (var resource in azureResources) - { - tasks.Add(ProcessResourceAsync(configuration, provisioningContextLazy, resource, cancellationToken)); - } - - var task = Task.WhenAll(tasks); - - // Suppress throwing so that we can save the deployment state even if the task fails - await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - - // Set the completion source for all resources - foreach (var resource in azureResources) - { - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetResult(); - } - } - - private async Task ProcessResourceAsync(IConfiguration configuration, Lazy> provisioningContextLazy, (IResource Resource, IAzureResource AzureResource) resource, CancellationToken cancellationToken) - { - var beforeResourceStartedEvent = new BeforeResourceStartedEvent(resource.Resource, serviceProvider); - await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false); - - var resourceLogger = loggerService.GetLogger(resource.AzureResource); - - // Only process AzureBicepResource resources - if (resource.AzureResource is not AzureBicepResource bicepResource) - { - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetResult(); - resourceLogger.LogInformation("Skipping {resourceName} because it is not a Bicep resource.", resource.AzureResource.Name); - return; - } - - if (bicepResource.IsContainer() || bicepResource.IsEmulator()) - { - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetResult(); - resourceLogger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.AzureResource.Name); - } - else if (await bicepProvisioner.ConfigureResourceAsync(configuration, bicepResource, cancellationToken).ConfigureAwait(false)) - { - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetResult(); - resourceLogger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.AzureResource.Name); - await PublishConnectionStringAvailableEventAsync().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); - } - - try - { - var provisioningContext = await provisioningContextLazy.Value.ConfigureAwait(false); - - await bicepProvisioner.GetOrCreateResourceAsync( - bicepResource, - provisioningContext, - cancellationToken).ConfigureAwait(false); - - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetResult(); - await PublishConnectionStringAvailableEventAsync().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."); - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetException(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."); - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetException(ex); - } - catch (Exception ex) - { - resourceLogger.LogError(ex, "Error provisioning {ResourceName}.", resource.AzureResource.Name); - resource.AzureResource.ProvisioningTaskCompletionSource?.TrySetException(new InvalidOperationException($"Unable to resolve references from {resource.AzureResource.Name}", ex)); - } - } - - async Task PublishConnectionStringAvailableEventAsync() - { - await PublishConnectionStringAvailableEventRecursiveAsync(resource.Resource).ConfigureAwait(false); - } - - async Task PublishConnectionStringAvailableEventRecursiveAsync(IResource targetResource) - { - // If the resource itself has a connection string then publish that the connection string is available. - if (targetResource is IResourceWithConnectionString) - { - var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(targetResource, serviceProvider); - await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); - } - - // Sometimes the container/executable itself does not have a connection string, and in those cases - // we need to dispatch the event for the children. - if (_parentChildLookup![targetResource] is { } children) - { - // only dispatch the event for children that have a connection string and are IResourceWithParent, not parented by annotations. - foreach (var child in children.OfType().Where(c => c is IResourceWithParent)) - { - await PublishConnectionStringAvailableEventRecursiveAsync(child).ConfigureAwait(false); - } - } - } + _ = provisioningController.EnsureProvisionedAsync(@event.Model, cancellationToken); + return Task.CompletedTask; } public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index e0bf100de2f..cba29c0a4e3 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -32,25 +32,32 @@ internal sealed class BicepProvisioner( /// public async Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) { - var section = configuration.GetSection($"Azure:Deployments:{resource.Name}"); - - if (!section.Exists()) + var stateSection = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + if (stateSection.Data.Count == 0) { return false; } - var currentCheckSum = await BicepUtilities.GetCurrentChecksumAsync(resource, section, cancellationToken).ConfigureAwait(false); - var configCheckSum = section["CheckSum"]; + var currentCheckSum = await BicepUtilities.GetCurrentChecksumAsync(resource, stateSection, cancellationToken).ConfigureAwait(false); + var configCheckSum = stateSection.Data["CheckSum"]?.GetValue(); + + if (string.IsNullOrEmpty(configCheckSum)) + { + logger.LogDebug("Cached deployment state for resource {ResourceName} is incomplete because it is missing a checksum.", resource.Name); + return false; + } - if (currentCheckSum != configCheckSum) + if (string.IsNullOrEmpty(currentCheckSum) || !string.Equals(currentCheckSum, configCheckSum, StringComparison.Ordinal)) { - logger.LogDebug("Checksum mismatch for resource {ResourceName}. Expected: {ExpectedChecksum}, Actual: {ActualChecksum}", resource.Name, currentCheckSum, configCheckSum); + logger.LogDebug("Checksum mismatch for resource {ResourceName}. Expected cached checksum {ExpectedChecksum}, computed checksum {ActualChecksum}", resource.Name, configCheckSum, currentCheckSum); return false; } logger.LogDebug("Configuring resource {ResourceName} from existing deployment state.", resource.Name); - if (section["Outputs"] is string outputJson) + var configuredLocation = GetConfiguredLocation(stateSection, configuration); + + if (stateSection.Data["Outputs"]?.GetValue() is { Length: > 0 } outputJson) { JsonNode? outputObj = null; try @@ -83,23 +90,27 @@ public async Task ConfigureResourceAsync(IConfiguration configuration, Azu var portalUrls = new List(); - if (section["Id"] is string deploymentId && - ResourceIdentifier.TryParse(deploymentId, out var id) && + string? deploymentId = null; + if (stateSection.Data["Id"]?.GetValue() is { Length: > 0 } configuredDeploymentId && + ResourceIdentifier.TryParse(configuredDeploymentId, out var id) && id is not null) { + deploymentId = configuredDeploymentId; portalUrls.Add(new(Name: "deployment", Url: GetDeploymentUrl(id), IsInternal: false)); } await notificationService.PublishUpdateAsync(resource, state => { - ImmutableArray props = [ - .. state.Properties, - new("azure.subscription.id", configuration["Azure:SubscriptionId"]), - // new("azure.resource.group", configuration["Azure:ResourceGroup"]!), - new("azure.tenant.domain", configuration["Azure:Tenant"]), - new("azure.location", configuration["Azure:Location"]), - new(CustomResourceKnownProperties.Source, section["Id"]) - ]; + // Reused deployment state should expose the same Azure identity metadata as a freshly provisioned resource + // so agents and commands can reliably locate the backing Azure deployment. + var props = state.Properties.SetResourcePropertyRange([ + new("azure.subscription.id", configuration["Azure:SubscriptionId"]), + new("azure.resource.group", configuration["Azure:ResourceGroup"]), + new("azure.tenant.id", configuration["Azure:TenantId"]), + new("azure.tenant.domain", configuration["Azure:Tenant"]), + new("azure.location", configuredLocation), + new(CustomResourceKnownProperties.Source, deploymentId) + ]); return state with { @@ -127,6 +138,8 @@ public async Task GetOrCreateResourceAsync(AzureBicepResource resource, Provisio resourceGroup = response.Value; } + var effectiveLocation = GetEffectiveLocation(resource, context); + await notificationService.PublishUpdateAsync(resource, state => state with { ResourceType = resource.GetType().Name, @@ -134,8 +147,9 @@ await notificationService.PublishUpdateAsync(resource, state => state with Properties = state.Properties.SetResourcePropertyRange([ new("azure.subscription.id", context.Subscription.Id.Name), new("azure.resource.group", resourceGroup.Id.Name), + new("azure.tenant.id", context.Tenant.TenantId?.ToString()), new("azure.tenant.domain", context.Tenant.DefaultDomain), - new("azure.location", context.Location.ToString()), + new("azure.location", effectiveLocation), ]) }).ConfigureAwait(false); @@ -166,13 +180,20 @@ await notificationService.PublishUpdateAsync(resource, state => var scope = new JsonObject(); await BicepUtilities.SetScopeAsync(scope, resource, cancellationToken: cancellationToken).ConfigureAwait(false); + // Resources with a Subscription scope should use a subscription-level deployment. + var deployments = resource.Scope?.Subscription != null + ? context.Subscription.GetArmDeployments() + : resourceGroup.GetArmDeployments(); + var deploymentName = executionContext.IsPublishMode ? $"{resource.Name}-{DateTimeOffset.Now.ToUnixTimeSeconds()}" : resource.Name; + var deploymentId = GetDeploymentId(context, resourceGroup, deploymentName); var sw = Stopwatch.StartNew(); await notificationService.PublishUpdateAsync(resource, state => { return state with { - State = new("Creating ARM Deployment", KnownResourceStateStyles.Info) + State = new("Creating ARM Deployment", KnownResourceStateStyles.Info), + Properties = state.Properties.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId.ToString()), }; }) .ConfigureAwait(false); @@ -180,12 +201,6 @@ await notificationService.PublishUpdateAsync(resource, state => resourceLogger.LogInformation("Deploying {Name} to {ResourceGroup}", resource.Name, resourceGroup.Name); logger.LogDebug("Starting deployment of resource {ResourceName} to resource group {ResourceGroupName}", resource.Name, resourceGroup.Name); - // Resources with a Subscription scope should use a subscription-level deployment. - var deployments = resource.Scope?.Subscription != null - ? context.Subscription.GetArmDeployments() - : resourceGroup.GetArmDeployments(); - var deploymentName = executionContext.IsPublishMode ? $"{resource.Name}-{DateTimeOffset.Now.ToUnixTimeSeconds()}" : resource.Name; - var deploymentContent = new ArmDeploymentContent(new(ArmDeploymentMode.Incremental) { Template = BinaryData.FromString(armTemplateContents), @@ -205,6 +220,7 @@ await notificationService.PublishUpdateAsync(resource, state => { State = new("Waiting for Deployment", KnownResourceStateStyles.Info), Urls = [.. state.Urls, new(Name: "deployment", Url: url, IsInternal: false)], + Properties = state.Properties.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId.ToString()), }; }) .ConfigureAwait(false); @@ -237,10 +253,19 @@ await notificationService.PublishUpdateAsync(resource, state => // Acquire resource-specific state section for thread-safe deployment state management var sectionName = $"Azure:Deployments:{resource.Name}"; var stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, cancellationToken).ConfigureAwait(false); + var locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue(); // Update deployment state for this specific resource stateSection.Data.Clear(); + // Only preserve a per-resource override when it still matches the resource we just deployed. This keeps + // run-mode reprovisioning sticky while allowing global context changes to clear stale overrides naturally. + if (!string.IsNullOrEmpty(locationOverride) && + string.Equals(locationOverride, effectiveLocation, StringComparison.OrdinalIgnoreCase)) + { + stateSection.Data[AzureProvisioningController.LocationOverrideKey] = locationOverride; + } + // Save the deployment id to the configuration stateSection.Data["Id"] = deployment.Id.ToString(); @@ -283,10 +308,14 @@ await notificationService.PublishUpdateAsync(resource, state => await notificationService.PublishUpdateAsync(resource, state => { - ImmutableArray properties = [ - .. state.Properties, - new(CustomResourceKnownProperties.Source, deployment.Id.Name) - ]; + ImmutableArray properties = state.Properties.SetResourcePropertyRange([ + new("azure.subscription.id", context.Subscription.Id.Name), + new("azure.resource.group", resourceGroup.Id.Name), + new("azure.tenant.id", context.Tenant.TenantId?.ToString()), + new("azure.tenant.domain", context.Tenant.DefaultDomain), + new("azure.location", effectiveLocation), + new(CustomResourceKnownProperties.Source, deployment.Id.ToString()) + ]); return state with { @@ -351,8 +380,10 @@ static void ValidateUnknownPrincipalParameter(ProvisioningContext context) resource.Parameters[AzureBicepResource.KnownParameters.PrincipalType] = "User"; } - // Always specify the location - resource.Parameters[AzureBicepResource.KnownParameters.Location] = context.Location.Name; + if (!resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var location) || location is null) + { + resource.Parameters[AzureBicepResource.KnownParameters.Location] = context.Location.Name; + } } private const string PortalDeploymentOverviewUrl = "https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id"; @@ -375,4 +406,36 @@ private static string GetDeploymentUrl(ProvisioningContext provisioningContext, public static string GetDeploymentUrl(ResourceIdentifier deploymentId) => $"{PortalDeploymentOverviewUrl}/{Uri.EscapeDataString(deploymentId.ToString())}"; + + private static ResourceIdentifier GetDeploymentId(ProvisioningContext provisioningContext, IResourceGroupResource resourceGroup, string deploymentName) + => new($"{provisioningContext.Subscription.Id}/resourceGroups/{resourceGroup.Name}/providers/Microsoft.Resources/deployments/{deploymentName}"); + + private static string GetConfiguredLocation(DeploymentStateSection section, IConfiguration configuration) + { + if (section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue() is { Length: > 0 } locationOverride) + { + return locationOverride; + } + + if (section.Data["Parameters"]?.GetValue() is { Length: > 0 } parametersJson) + { + try + { + if (JsonNode.Parse(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue() is { Length: > 0 } configuredLocation) + { + return configuredLocation; + } + } + catch + { + } + } + + return configuration["Azure:Location"] ?? string.Empty; + } + + private static string GetEffectiveLocation(AzureBicepResource resource, ProvisioningContext context) => + resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var location) && location is not null + ? location.ToString() ?? context.Location.ToString() + : context.Location.ToString(); } diff --git a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs index 695b8f56856..9600f825b4d 100644 --- a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs +++ b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs @@ -295,5 +295,266 @@ internal static string ResourceGroupSelectionMessage { return ResourceManager.GetString("ResourceGroupSelectionMessage", resourceCulture); } } + + /// + /// Looks up a localized string similar to This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next.. + /// + internal static string ChangeAzureContextCommandConfirmation { + get { + return ResourceManager.GetString("ChangeAzureContextCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources.. + /// + internal static string ChangeAzureContextCommandDescription { + get { + return ResourceManager.GetString("ChangeAzureContextCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Change Azure context. + /// + internal static string ChangeAzureContextCommandName { + get { + return ResourceManager.GetString("ChangeAzureContextCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure context updated and resources reprovisioned.. + /// + internal static string ChangeAzureContextCommandSuccess { + get { + return ResourceManager.GetString("ChangeAzureContextCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue?. + /// + internal static string DeleteAzureResourcesCommandConfirmation { + get { + return ResourceManager.GetString("DeleteAzureResourcesCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning.. + /// + internal static string DeleteAzureResourcesCommandDescription { + get { + return ResourceManager.GetString("DeleteAzureResourcesCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Azure resources. + /// + internal static string DeleteAzureResourcesCommandName { + get { + return ResourceManager.GetString("DeleteAzureResourcesCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure resources deleted and provisioning state reset.. + /// + internal static string DeleteAzureResourcesCommandSuccess { + get { + return ResourceManager.GetString("DeleteAzureResourcesCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This clears the cached Azure deployment state for this resource and resets its provisioning snapshot.. + /// + internal static string ForgetStateCommandConfirmation { + get { + return ResourceManager.GetString("ForgetStateCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context.. + /// + internal static string ForgetStateCommandDescription { + get { + return ResourceManager.GetString("ForgetStateCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Forget state. + /// + internal static string ForgetStateCommandName { + get { + return ResourceManager.GetString("ForgetStateCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure resource provisioning state reset.. + /// + internal static string ForgetStateCommandSuccess { + get { + return ResourceManager.GetString("ForgetStateCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Overrides the Azure location for this resource and reprovisions it using that location.. + /// + internal static string ChangeResourceLocationCommandDescription { + get { + return ResourceManager.GetString("ChangeResourceLocationCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Change location. + /// + internal static string ChangeResourceLocationCommandName { + get { + return ResourceManager.GetString("ChangeResourceLocationCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure resource location updated and reprovisioning completed.. + /// + internal static string ChangeResourceLocationCommandSuccess { + get { + return ResourceManager.GetString("ChangeResourceLocationCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location.. + /// + internal static string ChangeResourceLocationPromptMessage { + get { + return ResourceManager.GetString("ChangeResourceLocationPromptMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Change Azure resource location. + /// + internal static string ChangeResourceLocationPromptTitle { + get { + return ResourceManager.GetString("ChangeResourceLocationPromptTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue?. + /// + internal static string ResetProvisioningStateCommandConfirmation { + get { + return ResourceManager.GetString("ResetProvisioningStateCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned.. + /// + internal static string ResetProvisioningStateCommandDescription { + get { + return ResourceManager.GetString("ResetProvisioningStateCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reset provisioning state. + /// + internal static string ResetProvisioningStateCommandName { + get { + return ResourceManager.GetString("ResetProvisioningStateCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure provisioning state reset.. + /// + internal static string ResetProvisioningStateCommandSuccess { + get { + return ResourceManager.GetString("ResetProvisioningStateCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location.. + /// + internal static string ReprovisionAllCommandConfirmation { + get { + return ResourceManager.GetString("ReprovisionAllCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context.. + /// + internal static string ReprovisionAllCommandDescription { + get { + return ResourceManager.GetString("ReprovisionAllCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reprovision all. + /// + internal static string ReprovisionAllCommandName { + get { + return ResourceManager.GetString("ReprovisionAllCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure reprovisioning completed.. + /// + internal static string ReprovisionAllCommandSuccess { + get { + return ResourceManager.GetString("ReprovisionAllCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location.. + /// + internal static string ReprovisionResourceCommandConfirmation { + get { + return ResourceManager.GetString("ReprovisionResourceCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context.. + /// + internal static string ReprovisionResourceCommandDescription { + get { + return ResourceManager.GetString("ReprovisionResourceCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reprovision. + /// + internal static string ReprovisionResourceCommandName { + get { + return ResourceManager.GetString("ReprovisionResourceCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure resource reprovisioning completed.. + /// + internal static string ReprovisionResourceCommandSuccess { + get { + return ResourceManager.GetString("ReprovisionResourceCommandSuccess", resourceCulture); + } + } } -} \ No newline at end of file +} diff --git a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx index 61b5cdbcde2..69929ce972c 100644 --- a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx +++ b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx @@ -198,4 +198,91 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Select your Azure resource group or enter a new name: - \ No newline at end of file + + Reset provisioning state + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + Azure provisioning state reset. + + + Change Azure context + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + Azure context updated and resources reprovisioned. + + + Forget state + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + Azure resource provisioning state reset. + + + Change location + + + Overrides the Azure location for this resource and reprovisions it using that location. + + + Azure resource location updated and reprovisioning completed. + + + Change Azure resource location + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + Reprovision + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + Azure resource reprovisioning completed. + + + Reprovision all + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + Azure reprovisioning completed. + + + Delete Azure resources + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + Azure resources deleted and provisioning state reset. + + diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf index dd233f60656..ebc2b203d67 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Zjistěte více v [dokumentaci k nasazení Azure](https://aka.ms/dotnet/aspire/a Zřizování Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Skupina prostředků Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf index a5b5377364e..3b67d11cf82 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Weitere Informationen finden Sie in der [Azure-Bereitstellungsdokumentation](htt Azure-Bereitstellung + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Azure-Ressourcengruppe diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf index fdfdc15c7bc..085a1ebf24f 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Para más información, consulte la [documentación de aprovisionamiento de Azur Aprovisionamiento de Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Grupo de recursos de Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf index f8b96af376b..b5146742ae3 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Pour en savoir plus, consultez la [documentation d’approvisionnement Azure](ht Approvisionnement Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Groupe de ressources Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf index eab6b2169ec..aa16ea5d1eb 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Per altre informazioni, vedere la [documentazione sul provisioning di Azure](htt Provisioning Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Gruppo di risorse di Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf index 327956d1349..9fecca28e86 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Azure のプロビジョニング + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Azure リソース グループ diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf index 5ce3f5c2e90..39e81e1fa03 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Azure 프로비전 + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Azure 리소스 그룹 diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf index 88d665bf4b1..4258fc5f76a 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Aby dowiedzieć się więcej, zobacz [dokumentację aprowizacji platformy Azure] Aprowizowanie platformy Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Grupa zasobów platformy Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pt-BR.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pt-BR.xlf index 6d7ec17325c..63a88563974 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pt-BR.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pt-BR.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Para saber mais, veja a [documentação de provisionamento do Azure](https://aka Provisionamento do Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group grupo de recursos do Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf index 568858243d1..eae3ad17631 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Подготовка Azure + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Группа ресурсов Azure diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf index a9592173fc6..8715809ee90 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ Daha fazla bilgi için [Azure sağlama belgelerine](https://aka.ms/dotnet/aspire Azure sağlama + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Azure kaynak grubu diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hans.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hans.xlf index 9c254f6996b..8d40e63731a 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hans.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Azure 预配 + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Azure 资源组 diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hant.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hant.xlf index 524a44ed8bb..f905ec6d0b3 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hant.xlf @@ -2,6 +2,91 @@ + + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + This updates the Azure context for this AppHost and reprovisions all Azure resources using the values you enter next. + + + + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + Updates the Azure tenant, subscription, resource group, or location for this AppHost and reprovisions all Azure resources. + + + + Change Azure context + Change Azure context + + + + Azure context updated and resources reprovisioned. + Azure context updated and resources reprovisioned. + + + + Overrides the Azure location for this resource and reprovisions it using that location. + Overrides the Azure location for this resource and reprovisions it using that location. + + + + Change location + Change location + + + + Azure resource location updated and reprovisioning completed. + Azure resource location updated and reprovisioning completed. + + + + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + Select or enter a new Azure location for '{0}'. The resource will then be reprovisioned using that location. + + + + Change Azure resource location + Change Azure resource location + + + + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + This deletes the current Azure resource group and every Azure resource inside it for this AppHost, then clears the cached deployment state. Do you want to continue? + + + + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + Deletes the current Azure resource group for this AppHost, clears cached deployment state, and leaves the Azure context intact for reprovisioning. + + + + Delete Azure resources + Delete Azure resources + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + This clears the cached Azure deployment state for this resource and resets its provisioning snapshot. + + + + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + Clears the cached Azure deployment state for this resource so it can be provisioned again using the current Azure context. + + + + Forget state + Forget state + + + + Azure resource provisioning state reset. + Azure resource provisioning state reset. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +136,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Azure 佈建 + + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this AppHost and reprovisions all Azure resources again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + Clears the cached Azure deployment state for this AppHost and provisions all Azure resources again using the current Azure context. + + + + Reprovision all + Reprovision all + + + + Azure reprovisioning completed. + Azure reprovisioning completed. + + + + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + This clears the cached Azure deployment state for this resource and reprovisions it again using the current Azure tenant, subscription, resource group, and location. + + + + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + Clears the cached Azure deployment state for this resource and provisions it again using the current Azure context. + + + + Reprovision + Reprovision + + + + Azure resource reprovisioning completed. + Azure resource reprovisioning completed. + + + + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + This only clears the cached Azure tenant, subscription, resource group, location, and deployment state stored for this AppHost. Live Azure resources are not deleted and may be left orphaned. Do you want to continue? + + + + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + Clears the cached Azure context and deployment state for this AppHost. This does not delete live Azure resources and may leave them orphaned. + + + + Reset provisioning state + Reset provisioning state + + + + Azure provisioning state reset. + Azure provisioning state reset. + + Azure resource group Azure 資源群組 diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs index 59b4bc9a822..0fc41629da8 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs @@ -10,8 +10,14 @@ using Aspire.Hosting.Azure.Provisioning.Internal; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Utils; +using Azure; using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Resources.Models; using Azure.Security.KeyVault.Secrets; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -139,6 +145,218 @@ public async Task GetOrCreateResourceAsync_InPublishMode_ThrowsForUnknownPrincip Assert.Contains("Azure principal parameter was not supplied", exception.Message); } + [Fact] + public async Task GetOrCreateResourceAsync_UsesEffectiveResourceLocationInSnapshot() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var resource = new AzureBicepResource("storage", templateString: "output name string = 'storage'"); + resource.Parameters[AzureBicepResource.KnownParameters.Location] = "westus3"; + + var provisioner = new BicepProvisioner( + services.GetRequiredService(), + services.GetRequiredService(), + new TestBicepCliExecutor(), + new TestSecretClientProvider(), + services.GetRequiredService(), + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + services.GetRequiredService(), + NullLogger.Instance); + + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(location: AzureLocation.WestUS2); + + await provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None); + + var notifications = services.GetRequiredService(); + Assert.True(notifications.TryGetCurrentState(resource.Name, out var resourceEvent)); + Assert.Equal("westus3", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.location").Value?.ToString()); + Assert.Equal("87654321-4321-4321-4321-210987654321", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.tenant.id").Value?.ToString()); + Assert.Equal("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/storage", resourceEvent.Snapshot.Properties.Single(p => p.Name == CustomResourceKnownProperties.Source).Value?.ToString()); + } + + [Fact] + public async Task GetOrCreateResourceAsync_PublishesPredictedDeploymentIdBeforeDeploymentStarts() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var resourceGroup = new ThrowingResourceGroupResource("test-rg"); + + var provisioner = new BicepProvisioner( + services.GetRequiredService(), + services.GetRequiredService(), + new TestBicepCliExecutor(), + new TestSecretClientProvider(), + services.GetRequiredService(), + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + services.GetRequiredService(), + NullLogger.Instance); + + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(resourceGroup: resourceGroup); + + await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + var notifications = services.GetRequiredService(); + Assert.True(notifications.TryGetCurrentState(resource.Name, out var resourceEvent)); + Assert.Equal("87654321-4321-4321-4321-210987654321", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.tenant.id").Value?.ToString()); + Assert.Equal("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/storage2", resourceEvent.Snapshot.Properties.Single(p => p.Name == CustomResourceKnownProperties.Source).Value?.ToString()); + } + + [Fact] + public async Task ConfigureResourceAsync_DoesNotReuseOverrideOnlyDeploymentState() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Azure:Deployments:storage2:LocationOverride"] = "westus3" + }) + .Build(); + + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + + var provisioner = new BicepProvisioner( + services.GetRequiredService(), + services.GetRequiredService(), + new TestBicepCliExecutor(), + new TestSecretClientProvider(), + services.GetRequiredService(), + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + services.GetRequiredService(), + NullLogger.Instance); + + var reused = await provisioner.ConfigureResourceAsync(configuration, resource, CancellationToken.None); + + Assert.False(reused); + Assert.Empty(resource.Outputs); + } + + [Fact] + public async Task ConfigureResourceAsync_PublishesAzureIdentityPropertiesFromCachedDeploymentState() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012", + ["Azure:ResourceGroup"] = "test-rg", + ["Azure:TenantId"] = "87654321-4321-4321-4321-210987654321", + ["Azure:Tenant"] = "microsoft.onmicrosoft.com", + ["Azure:Location"] = "westus2" + }) + .Build(); + + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var parameters = new JsonObject(); + var checksum = BicepUtilities.GetChecksum(resource, parameters, scope: null); + + var deploymentStateManager = services.GetRequiredService(); + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + section.Data["Id"] = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/storage2"; + section.Data["Parameters"] = parameters.ToJsonString(); + section.Data["Outputs"] = """{"name":{"value":"storage2"}}"""; + section.Data["CheckSum"] = checksum; + await deploymentStateManager.SaveSectionAsync(section, CancellationToken.None); + + var provisioner = new BicepProvisioner( + services.GetRequiredService(), + services.GetRequiredService(), + new TestBicepCliExecutor(), + new TestSecretClientProvider(), + deploymentStateManager, + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + services.GetRequiredService(), + NullLogger.Instance); + + var reused = await provisioner.ConfigureResourceAsync(configuration, resource, CancellationToken.None); + + Assert.True(reused); + + var notifications = services.GetRequiredService(); + Assert.True(notifications.TryGetCurrentState(resource.Name, out var resourceEvent)); + Assert.Equal("12345678-1234-1234-1234-123456789012", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.subscription.id").Value?.ToString()); + Assert.Equal("test-rg", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.resource.group").Value?.ToString()); + Assert.Equal("87654321-4321-4321-4321-210987654321", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.tenant.id").Value?.ToString()); + Assert.Equal("microsoft.onmicrosoft.com", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.tenant.domain").Value?.ToString()); + Assert.Equal("westus2", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.location").Value?.ToString()); + Assert.Equal("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/storage2", resourceEvent.Snapshot.Properties.Single(p => p.Name == CustomResourceKnownProperties.Source).Value?.ToString()); + } + + [Fact] + public async Task GetOrCreateResourceAsync_PreservesLocationOverrideInDeploymentState() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + section.Data[AzureProvisioningController.LocationOverrideKey] = "westus3"; + await deploymentStateManager.SaveSectionAsync(section, CancellationToken.None); + + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + resource.Parameters[AzureBicepResource.KnownParameters.Location] = "westus3"; + + var provisioner = new BicepProvisioner( + services.GetRequiredService(), + services.GetRequiredService(), + new TestBicepCliExecutor(), + new TestSecretClientProvider(), + deploymentStateManager, + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + services.GetRequiredService(), + NullLogger.Instance); + + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(location: AzureLocation.WestUS2); + + await provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None); + + section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.Equal("westus3", section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [Fact] + public async Task GetOrCreateResourceAsync_ClearsStaleLocationOverrideWhenEffectiveLocationChanges() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + section.Data[AzureProvisioningController.LocationOverrideKey] = "westus3"; + await deploymentStateManager.SaveSectionAsync(section, CancellationToken.None); + + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + + var provisioner = new BicepProvisioner( + services.GetRequiredService(), + services.GetRequiredService(), + new TestBicepCliExecutor(), + new TestSecretClientProvider(), + deploymentStateManager, + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run), + services.GetRequiredService(), + NullLogger.Instance); + + var context = ProvisioningTestHelpers.CreateTestProvisioningContext(location: AzureLocation.WestUS2); + + await provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None); + + section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.False(section.Data.ContainsKey(AzureProvisioningController.LocationOverrideKey)); + } + [Fact] public async Task BicepCliExecutor_CompilesBicepToArm() { @@ -270,4 +488,32 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c return Task.CompletedTask; } } + + private sealed class ThrowingResourceGroupResource(string name) : IResourceGroupResource + { + private int _deleteCallCount; + + public int DeleteCallCount => _deleteCallCount; + + public ResourceIdentifier Id => new($"/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/{name}"); + public string Name => name; + + public IArmDeploymentCollection GetArmDeployments() => new ThrowingArmDeploymentCollection(); + + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) + { + _deleteCallCount++; + return Task.FromResult(new TestDeleteArmOperation()); + } + } + + private sealed class ThrowingArmDeploymentCollection : IArmDeploymentCollection + { + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + throw new RequestFailedException(409, "Deployment creation failed."); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs index 11e68de78ca..30eea4923cb 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs @@ -1,10 +1,23 @@ #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. +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json.Nodes; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Eventing; using Aspire.Hosting.Utils; +using Aspire.Hosting.Azure.Provisioning; +using Aspire.Hosting.Azure.Provisioning.Internal; +using Aspire.Hosting.Pipelines; +using Aspire.Hosting.Tests; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure.Tests; @@ -45,7 +58,7 @@ public void AddAzureEnvironment_CalledMultipleTimes_ReturnsSameResource() } [Fact] - public void AddAzureEnvironment_InRunMode_DoesNotAddToResources() + public void AddAzureEnvironment_InRunMode_AddsControlResourceWithResetCommand() { // Arrange var builder = CreateBuilder(isRunMode: true); @@ -55,7 +68,1059 @@ public void AddAzureEnvironment_InRunMode_DoesNotAddToResources() // Assert Assert.NotNull(resourceBuilder); - Assert.Empty(builder.Resources.OfType()); + var environmentResource = Assert.Single(builder.Resources.OfType()); + var resetCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ResetProvisioningStateCommandName); + Assert.Equal("Reset provisioning state", resetCommand.DisplayName); + Assert.Contains("not delete live Azure resources", resetCommand.DisplayDescription); + Assert.Contains("may be left orphaned", resetCommand.ConfirmationMessage); + } + + [Fact] + public async Task ResetProvisioningStateCommand_ClearsCachedStateAndResetsSnapshots() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "sub"; + azureSection.Data["Location"] = "westus2"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Resources/deployments/storage"; + storageSection.Data["CheckSum"] = "checksum"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + storage.Resource.Outputs["blobEndpoint"] = "https://storage.blob.core.windows.net/"; + + await notifications.PublishUpdateAsync(environmentResource, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("azure.subscription.id", "sub") + ] + }); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with + { + State = KnownResourceStates.Running, + Urls = + [ + new("deployment", "https://portal.azure.com", false) + ], + Properties = + [ + new("azure.subscription.id", "sub"), + new(CustomResourceKnownProperties.Source, "deployment-id"), + new("custom.property", "keep") + ] + }); + + var resetCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ResetProvisioningStateCommandName); + + var result = await resetCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None + }); + + Assert.True(result.Success); + Assert.Equal("Azure provisioning state reset.", result.Result); + + azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + Assert.Empty(azureSection.Data); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + + Assert.Empty(storage.Resource.Outputs); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.Equal(KnownResourceStates.NotStarted, environmentEvent.Snapshot.State?.Text); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Enabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal(KnownResourceStates.NotStarted, storageEvent.Snapshot.State?.Text); + Assert.Empty(storageEvent.Snapshot.Urls); + Assert.DoesNotContain(storageEvent.Snapshot.Properties, p => p.Name == "azure.subscription.id"); + Assert.DoesNotContain(storageEvent.Snapshot.Properties, p => p.Name == CustomResourceKnownProperties.Source); + Assert.Contains(storageEvent.Snapshot.Properties, p => p.Name == "custom.property"); + } + + [Fact] + public async Task EnsureProvisionedAsync_UsesControllerProvisioningFlow() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddAzureStorage("storage"); + + using var app = builder.Build(); + + var controller = app.Services.GetRequiredService(); + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + + await controller.EnsureProvisionedAsync(model); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal("Running", storageEvent.Snapshot.State?.Text); + Assert.Equal("https://storage.blob.core.windows.net/", storage.Resource.Outputs["blobEndpoint"]); + } + + [Fact] + public async Task OnBeforeStartAsync_AddsPerResourceCommandsToDeployableAzureResourcesOnly() + { + var builder = CreateBuilder(isRunMode: true); + builder.AddAzureProvisioning(); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blobs"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + Assert.Contains(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ForgetStateCommandName); + Assert.Contains(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + Assert.Contains(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + Assert.DoesNotContain(blobs.Resource.Annotations.OfType(), c => + c.Name == AzureProvisioningController.ForgetStateCommandName || + c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + } + + [Fact] + public async Task ForgetStateCommand_ClearsOnlyTargetedResourceStateAndSnapshots() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + var storage2 = builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = "storage-deployment"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + storage2Section.Data["Id"] = "storage2-deployment"; + await deploymentStateManager.SaveSectionAsync(storage2Section); + + storage.Resource.Outputs["blobEndpoint"] = "https://storage.blob.core.windows.net/"; + storage2.Resource.Outputs["blobEndpoint"] = "https://storage2.blob.core.windows.net/"; + + await notifications.PublishUpdateAsync(storage.Resource, state => state with + { + State = KnownResourceStates.Running, + Urls = [new("deployment", "https://portal.azure.com/storage", false)], + Properties = [new(CustomResourceKnownProperties.Source, "storage-deployment"), new("custom.property", "keep-storage")] + }); + + await notifications.PublishUpdateAsync(storage2.Resource, state => state with + { + State = KnownResourceStates.Running, + Urls = [new("deployment", "https://portal.azure.com/storage2", false)], + Properties = [new(CustomResourceKnownProperties.Source, "storage2-deployment"), new("custom.property", "keep-storage2")] + }); + + var forgetCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ForgetStateCommandName); + + var result = await forgetCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource provisioning state reset.", result.Result); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + + storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + Assert.Equal("storage2-deployment", storage2Section.Data["Id"]?.GetValue()); + + Assert.Empty(storage.Resource.Outputs); + Assert.Equal("https://storage2.blob.core.windows.net/", storage2.Resource.Outputs["blobEndpoint"]); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal(KnownResourceStates.NotStarted, storageEvent.Snapshot.State?.Text); + Assert.DoesNotContain(storageEvent.Snapshot.Properties, p => p.Name == CustomResourceKnownProperties.Source); + Assert.Contains(storageEvent.Snapshot.Properties, p => p.Name == "custom.property"); + + Assert.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + Assert.Equal(KnownResourceStates.Running, storage2Event.Snapshot.State?.Text); + Assert.Contains(storage2Event.Snapshot.Properties, p => p.Name == CustomResourceKnownProperties.Source); + } + + [Fact] + public async Task ReprovisionCommand_ReprovisionsOnlyTargetedResource() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + var storage2 = builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = "storage-deployment"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + storage2Section.Data["Id"] = "storage2-deployment"; + await deploymentStateManager.SaveSectionAsync(storage2Section); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + await notifications.PublishUpdateAsync(storage2.Resource, state => state with { State = KnownResourceStates.Running }); + + var reprovisionCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + + var result = await reprovisionCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource reprovisioning completed.", result.Result); + Assert.Contains(storage.Resource.Outputs, output => output.Key == "blobEndpoint"); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + + storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + Assert.Equal("storage2-deployment", storage2Section.Data["Id"]?.GetValue()); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal("Running", storageEvent.Snapshot.State?.Text); + + Assert.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + Assert.Equal(KnownResourceStates.Running, storage2Event.Snapshot.State?.Text); + } + + [Fact] + public async Task ChangeLocationCommand_PersistsOverrideAndReprovisionsTargetedResource() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var testInteractionService = new TestInteractionService(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(testInteractionService); + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + var storage2 = builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = new("Failed to Provision", KnownResourceStateStyles.Error) }); + await notifications.PublishUpdateAsync(storage2.Resource, state => state with { State = KnownResourceStates.Running }); + + var changeLocationCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + + var executionTask = changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + var interaction = await testInteractionService.Interactions.Reader.ReadAsync(); + interaction.Inputs[AzureBicepResource.KnownParameters.Location].Value = "westus2"; + interaction.CompletionTcs.SetResult(InteractionResult.Ok(interaction.Inputs)); + + var result = await executionTask; + + Assert.True(result.Success); + Assert.Equal("Azure resource location updated and reprovisioning completed.", result.Result); + Assert.Equal("westus2", testBicepProvisioner.ProvisionedLocations["storage"]); + Assert.DoesNotContain("storage2", testBicepProvisioner.ProvisionedLocations.Keys); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal("westus2", storageSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [Fact] + public async Task ChangeLocationCommand_UsesPersistedAzureContextForSelectableLocations() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var testInteractionService = new TestInteractionService(); + + builder.Services.AddSingleton(testInteractionService); + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "eastus"; + azureSection.Data["ResourceGroup"] = "test-rg"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + + var changeLocationCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + + var executionTask = changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + var interaction = await testInteractionService.Interactions.Reader.ReadAsync(); + var locationInput = interaction.Inputs[AzureBicepResource.KnownParameters.Location]; + + Assert.Equal(InputType.Choice, locationInput.InputType); + var options = Assert.IsAssignableFrom>>(locationInput.Options); + Assert.Contains(options, option => option.Key == "westus2"); + + locationInput.Value = "westus2"; + interaction.CompletionTcs.SetResult(InteractionResult.Ok(interaction.Inputs)); + + var result = await executionTask; + + Assert.True(result.Success); + Assert.Equal("Azure resource location updated and reprovisioning completed.", result.Result); + } + + [Fact] + public async Task ChangeLocationCommand_DeletesCachedResourceBeforeReprovisioningNewLocation() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var testInteractionService = new TestInteractionService(); + var deletedResourceIds = new List(); + const string resourceId = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage26wmkwq4f4li52"; + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = "test-rg"; + builder.Services.AddSingleton(testInteractionService); + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider([resourceId], deletedResourceIds)); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Outputs"] = """{"id":{"value":"/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage26wmkwq4f4li52"}}"""; + await deploymentStateManager.SaveSectionAsync(storageSection); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with + { + State = KnownResourceStates.Running, + Properties = [new("azure.location", "westus2")] + }); + + var changeLocationCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + + var executionTask = changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + var interaction = await testInteractionService.Interactions.Reader.ReadAsync(); + interaction.Inputs[AzureBicepResource.KnownParameters.Location].Value = "westus3"; + interaction.CompletionTcs.SetResult(InteractionResult.Ok(interaction.Inputs)); + + var result = await executionTask; + + Assert.True(result.Success); + Assert.Equal([resourceId], deletedResourceIds); + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage"]); + } + + [Fact] + public async Task ReprovisionAllCommand_PreservesAzureContextState() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + var storage2 = builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = "test-rg"; + azureSection.Data["TenantId"] = "87654321-4321-4321-4321-210987654321"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = "storage-deployment"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + storage2Section.Data["Id"] = "storage2-deployment"; + await deploymentStateManager.SaveSectionAsync(storage2Section); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + await notifications.PublishUpdateAsync(storage2.Resource, state => state with { State = KnownResourceStates.Running }); + + var reprovisionAllCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionAllCommandName); + + var result = await reprovisionAllCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None + }); + + Assert.True(result.Success); + Assert.Equal("Azure reprovisioning completed.", result.Result); + + azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + Assert.Equal("12345678-1234-1234-1234-123456789012", azureSection.Data["SubscriptionId"]?.GetValue()); + Assert.Equal("westus2", azureSection.Data["Location"]?.GetValue()); + Assert.Equal("test-rg", azureSection.Data["ResourceGroup"]?.GetValue()); + Assert.Equal("87654321-4321-4321-4321-210987654321", azureSection.Data["TenantId"]?.GetValue()); + } + + [Fact] + public async Task ReprovisionAllCommand_NormalizesPersistedLocationOverride() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var controller = app.Services.GetRequiredService(); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = "test-rg"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + storage2Section.Data[AzureProvisioningController.LocationOverrideKey] = "West US 3"; + await deploymentStateManager.SaveSectionAsync(storage2Section); + + await controller.ReprovisionAllAsync(model); + + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage2"]); + + storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + Assert.Equal("westus3", storage2Section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [Fact] + public async Task ReprovisionAllCommand_PreservesLocationOverrideFromPersistedParameters() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var controller = app.Services.GetRequiredService(); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = "test-rg"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + storage2Section.Data["Parameters"] = """{"location":{"value":"westus3"}}"""; + await deploymentStateManager.SaveSectionAsync(storage2Section); + + await controller.ReprovisionAllAsync(model); + + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage2"]); + + storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + Assert.Equal("westus3", storage2Section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [Fact] + public async Task ReprovisionResourceCommand_PreservesInMemoryLocationOverrideWhenCachedStateIsMissing() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + builder.AddBicepTemplateString("storage2", "resource storage2 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = "test-rg"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storage2 = model.Resources.OfType().Single(r => r.Name == "storage2"); + await notifications.PublishUpdateAsync(storage2, state => state with + { + State = KnownResourceStates.Running, + Properties = + [ + new("azure.location", "westus3"), + new("azure.subscription.id", "12345678-1234-1234-1234-123456789012") + ] + }); + + var reprovisionCommand = Assert.Single(storage2.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + + var result = await reprovisionCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage2.Name, + CancellationToken = CancellationToken.None + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource reprovisioning completed.", result.Result); + + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage2"]); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + Assert.Equal("westus3", storage2Section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [Fact] + public async Task ReprovisionResourceCommand_FailsWhenProvisioningFails() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + new ThrowingTestBicepProvisioner(), + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + + var reprovisionCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + + var result = await reprovisionCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + Assert.False(result.Success); + } + + [Fact] + public async Task ChangeLocationCommand_FailsWhenProvisioningFails() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var testInteractionService = new TestInteractionService(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(testInteractionService); + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + new ThrowingTestBicepProvisioner(), + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + + var changeLocationCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + + var executionTask = changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None + }); + + var interaction = await testInteractionService.Interactions.Reader.ReadAsync(); + interaction.Inputs[AzureBicepResource.KnownParameters.Location].Value = "westus2"; + interaction.CompletionTcs.SetResult(InteractionResult.Ok(interaction.Inputs)); + + var result = await executionTask; + + Assert.False(result.Success); + } + + [Fact] + public async Task CheckForDriftAsync_MarksResourceMissingInAzure() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider(existingResourceIds: [])); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + builder.AddAzureProvisioning(); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + var controller = app.Services.GetRequiredService(); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = "test-rg"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Outputs"] = """{"id":{"value":"/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage"}}"""; + await deploymentStateManager.SaveSectionAsync(storageSection); + + await notifications.PublishUpdateAsync(environmentResource, state => state with { State = KnownResourceStates.Running }); + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + + await controller.CheckForDriftAsync(model); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.Equal(AzureProvisioningController.DriftedState, environmentEvent.Snapshot.State?.Text); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var resourceEvent)); + Assert.Equal(AzureProvisioningController.MissingInAzureState, resourceEvent.Snapshot.State?.Text); + } + + [Fact] + public async Task DeleteAzureResourcesCommand_DeletesCurrentResourceGroupAndPreservesAzureContextState() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var resourceGroup = new TestResourceGroupResource("test-rg"); + var testProvisioningContextProvider = new TestProvisioningContextProvider(ProvisioningTestHelpers.CreateTestProvisioningContext(resourceGroup: resourceGroup)); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = builder.AddBicepTemplateString("storage", "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = "test-rg"; + azureSection.Data["TenantId"] = "87654321-4321-4321-4321-210987654321"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = "storage-deployment"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var deleteCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.DeleteAzureResourcesCommandName); + + var result = await deleteCommand.ExecuteCommand(new ExecuteCommandContext + { + ServiceProvider = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None + }); + + Assert.True(result.Success); + Assert.Equal("Azure resources deleted and provisioning state reset.", result.Result); + Assert.Equal(1, resourceGroup.DeleteCallCount); + + azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + Assert.Equal("12345678-1234-1234-1234-123456789012", azureSection.Data["SubscriptionId"]?.GetValue()); + Assert.Equal("westus2", azureSection.Data["Location"]?.GetValue()); + Assert.Equal("test-rg", azureSection.Data["ResourceGroup"]?.GetValue()); + Assert.Equal("87654321-4321-4321-4321-210987654321", azureSection.Data["TenantId"]?.GetValue()); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + + Assert.Empty(storage.Resource.Outputs); + } + + [Fact] + public async Task EnsureProvisioned_WaitsForReferencedAzureResources() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var testBicepProvisioner = new BlockingTestBicepProvisioner(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + testBicepProvisioner, + deploymentStateManager, + sp.GetRequiredService(), + testProvisioningContextProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + var storage = new AzureProvisioningResource("storage", _ => { }); + storage.Outputs["name"] = "storage"; + var storageRoles = new AzureProvisioningResource("storage-roles", infra => + { + new BicepOutputReference("name", storage).AsProvisioningParameter(infra); + }); + builder.AddResource(storageRoles); + builder.AddResource(storage); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var controller = app.Services.GetRequiredService(); + + var reprovisionTask = controller.EnsureProvisionedAsync(model, CancellationToken.None); + + await testBicepProvisioner.FirstProvisionStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(["storage"], testBicepProvisioner.ProvisionedResources); + + testBicepProvisioner.AllowFirstProvisionToComplete.TrySetResult(); + await reprovisionTask; + + Assert.Equal(["storage", "storage-roles"], testBicepProvisioner.ProvisionedResources); } [Fact] @@ -109,4 +1174,113 @@ private static IDistributedApplicationBuilder CreateBuilder(bool isRunMode = fal var operation = isRunMode ? DistributedApplicationOperation.Run : DistributedApplicationOperation.Publish; return TestDistributedApplicationBuilder.Create(operation); } + + private sealed class TestDeploymentStateManager : IDeploymentStateManager + { + private readonly Dictionary _sections = new(StringComparer.Ordinal); + + public string? StateFilePath => null; + + public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default) + { + _sections.TryGetValue(sectionName, out var existingData); + var data = existingData?.DeepClone().AsObject() ?? []; + + return Task.FromResult(new DeploymentStateSection(sectionName, data, version: 0)); + } + + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + _sections.Remove(section.SectionName); + return Task.CompletedTask; + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + _sections[section.SectionName] = section.Data.DeepClone().AsObject(); + return Task.CompletedTask; + } + } + + private sealed class TestBicepProvisioner : IBicepProvisioner + { + public int ConfigureResourceCallCount { get; private set; } + + public int GetOrCreateResourceCallCount { get; private set; } + + public List ConfiguredResources { get; } = []; + + public List ProvisionedResources { get; } = []; + public Dictionary ProvisionedLocations { get; } = new(StringComparer.Ordinal); + + public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) + { + ConfigureResourceCallCount++; + ConfiguredResources.Add(resource.Name); + return Task.FromResult(false); + } + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + GetOrCreateResourceCallCount++; + ProvisionedResources.Add(resource.Name); + ProvisionedLocations[resource.Name] = resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var location) + ? location?.ToString() + : null; + resource.Outputs["blobEndpoint"] = "https://storage.blob.core.windows.net/"; + return Task.CompletedTask; + } + } + + private sealed class TestProvisioningContextProvider : IProvisioningContextProvider + { + private readonly ProvisioningContext _context; + + public TestProvisioningContextProvider() + : this(ProvisioningTestHelpers.CreateTestProvisioningContext()) + { + } + + public TestProvisioningContextProvider(ProvisioningContext context) + { + _context = context; + } + + public int CreateProvisioningContextCallCount { get; private set; } + + public Task CreateProvisioningContextAsync(CancellationToken cancellationToken = default) + { + CreateProvisioningContextCallCount++; + return Task.FromResult(_context); + } + } + + private sealed class BlockingTestBicepProvisioner : IBicepProvisioner + { + public TaskCompletionSource FirstProvisionStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowFirstProvisionToComplete { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public List ProvisionedResources { get; } = []; + + public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) => Task.FromResult(false); + + public async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + ProvisionedResources.Add(resource.Name); + if (ProvisionedResources.Count == 1) + { + FirstProvisionStarted.TrySetResult(); + await AllowFirstProvisionToComplete.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + resource.Outputs["blobEndpoint"] = $"https://{resource.Name}.blob.core.windows.net/"; + } + } + + private sealed class ThrowingTestBicepProvisioner : IBicepProvisioner + { + public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) => Task.FromResult(false); + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + => Task.FromException(new InvalidOperationException("boom")); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs index a8c5dc5105e..c5be1171b89 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs @@ -6,10 +6,12 @@ #pragma warning disable ASPIREPIPELINES001 using System.Reflection; +using Aspire.Hosting.Azure.Provisioning; using Aspire.Hosting.Azure.Provisioning.Internal; using Aspire.Hosting.Pipelines; using Aspire.Hosting.Tests; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure.Tests; @@ -84,6 +86,42 @@ public async Task CreateProvisioningContextAsync_ThrowsWhenSubscriptionIdMissing Assert.Contains("Azure subscription id is required", exception.Message); } + [Fact] + public async Task CreateProvisioningContextAsync_DoesNotReuseStaleInMemoryOptionsAfterReset() + { + // Arrange + var optionValues = new AzureProvisionerOptions(); + var options = Options.Create(optionValues); + var environment = ProvisioningTestHelpers.CreateEnvironment(); + var logger = ProvisioningTestHelpers.CreateLogger(); + var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); + var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider(); + var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider(); + var deploymentStateManager = ProvisioningTestHelpers.CreateUserSecretsManager(); + + var provider = new RunModeProvisioningContextProvider( + _defaultInteractionService, + options, + environment, + logger, + armClientProvider, + userPrincipalProvider, + tokenCredentialProvider, + deploymentStateManager, + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + + // Simulate previously prompted values still hanging around in memory after reset. + optionValues.SubscriptionId = "12345678-1234-1234-1234-123456789012"; + optionValues.Location = "westus2"; + optionValues.ResourceGroup = "stale-rg"; + optionValues.AllowResourceGroupCreation = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => provider.CreateProvisioningContextAsync(CancellationToken.None)); + Assert.Contains("Azure subscription id is required", exception.Message); + } + [Fact] public async Task CreateProvisioningContextAsync_ThrowsWhenLocationMissing() { diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index 41e2cd705b5..d0b7b37de2f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -61,6 +61,8 @@ public static ProvisioningContext CreateTestProvisioningContext( public static IArmClientProvider CreateArmClientProvider() => new TestArmClientProvider(); public static IArmClientProvider CreateArmClientProvider(Dictionary deploymentOutputs) => new TestArmClientProvider(deploymentOutputs); public static IArmClientProvider CreateArmClientProvider(Func> deploymentOutputsProvider) => new TestArmClientProvider(deploymentOutputsProvider); + public static IArmClientProvider CreateArmClientProvider(IEnumerable existingResourceIds) => new TestArmClientProvider(existingResourceIds: existingResourceIds); + public static IArmClientProvider CreateArmClientProvider(IEnumerable existingResourceIds, List deletedResourceIds) => new TestArmClientProvider(existingResourceIds: existingResourceIds, deletedResourceIds: deletedResourceIds); public static ITokenCredentialProvider CreateTokenCredentialProvider() => new TestTokenCredentialProvider(); public static ISecretClientProvider CreateSecretClientProvider() => new TestSecretClientProvider(CreateTokenCredentialProvider()); public static IBicepCompiler CreateBicepCompiler() => new TestBicepCompiler(); @@ -181,6 +183,8 @@ internal sealed class TestArmClient : IArmClient { private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; + private readonly HashSet? _existingResourceIds; + private readonly List? _deletedResourceIds; public TestArmClient(Dictionary deploymentOutputs) { @@ -192,7 +196,13 @@ public TestArmClient(Func> deploymentOutputsP _deploymentOutputsProvider = deploymentOutputsProvider; } - public TestArmClient() : this([]) + public TestArmClient(IEnumerable existingResourceIds, List? deletedResourceIds = null) + { + _existingResourceIds = new HashSet(existingResourceIds, StringComparer.OrdinalIgnoreCase); + _deletedResourceIds = deletedResourceIds; + } + + public TestArmClient() : this(new Dictionary()) { } @@ -247,7 +257,8 @@ public Task> GetAvailableSubscriptionsAsync(s { ("eastus", "East US"), ("westus", "West US"), - ("westus2", "West US 2") + ("westus2", "West US 2"), + ("westus3", "West US 3") }; return Task.FromResult>(locations); } @@ -262,6 +273,19 @@ public Task> GetAvailableSubscriptionsAsync(s }; return Task.FromResult>(resourceGroups); } + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + { + var exists = _existingResourceIds is null || _existingResourceIds.Contains(resourceId); + return Task.FromResult(exists); + } + + public Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + { + _existingResourceIds?.Remove(resourceId); + _deletedResourceIds?.Add(resourceId); + return Task.CompletedTask; + } } /// @@ -386,6 +410,8 @@ public TestResourceGroupResource(string name = "test-rg") : this(name, []) { } + public int DeleteCallCount { get; private set; } + public ResourceIdentifier Id { get; } = new ResourceIdentifier("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg"); public string Name => _name; @@ -397,6 +423,12 @@ public IArmDeploymentCollection GetArmDeployments() } return new TestArmDeploymentCollection(_deploymentOutputs!); } + + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) + { + DeleteCallCount++; + return Task.FromResult(new TestDeleteArmOperation()); + } } /// @@ -470,6 +502,18 @@ internal sealed class TestArmOperation(T value) : ArmOperation public override Response WaitForCompletion(TimeSpan pollingInterval, CancellationToken cancellationToken = default) => Response.FromValue(Value, new MockResponse(200)); } +internal sealed class TestDeleteArmOperation : ArmOperation +{ + public override string Id { get; } = Guid.NewGuid().ToString(); + public override bool HasCompleted { get; } = true; + + public override Response GetRawResponse() => new MockResponse(200); + public override Response UpdateStatus(CancellationToken cancellationToken = default) => new MockResponse(200); + public override ValueTask UpdateStatusAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(new MockResponse(200)); + public override ValueTask WaitForCompletionResponseAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult(new MockResponse(200)); + public override ValueTask WaitForCompletionResponseAsync(TimeSpan pollingInterval, CancellationToken cancellationToken = default) => ValueTask.FromResult(new MockResponse(200)); +} + /// /// Test implementation of ArmDeploymentResource for testing. /// @@ -542,6 +586,8 @@ internal sealed class TestArmClientProvider : IArmClientProvider { private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; + private readonly IEnumerable? _existingResourceIds; + private readonly List? _deletedResourceIds; public TestArmClientProvider(Dictionary deploymentOutputs) { @@ -553,7 +599,13 @@ public TestArmClientProvider(Func> deployment _deploymentOutputsProvider = deploymentOutputsProvider; } - public TestArmClientProvider() : this([]) + public TestArmClientProvider(IEnumerable existingResourceIds, List? deletedResourceIds = null) + { + _existingResourceIds = existingResourceIds; + _deletedResourceIds = deletedResourceIds; + } + + public TestArmClientProvider() : this(new Dictionary()) { } @@ -563,6 +615,10 @@ public IArmClient GetArmClient(TokenCredential credential, string subscriptionId { return new TestArmClient(_deploymentOutputsProvider); } + if (_existingResourceIds is not null) + { + return new TestArmClient(_existingResourceIds, _deletedResourceIds); + } return new TestArmClient(_deploymentOutputs!); }