From d478a206b56adeced4b9d56a7b5a39358e6b3a6e Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 9 Jun 2026 21:58:28 -0700 Subject: [PATCH] Refactor Azure provisioning dashboard commands Refactor Azure provisioning run-mode commands and dashboard state handling, add deterministic command/input coverage, and document the controller behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Model/Interaction/InputViewModel.cs | 14 +- src/Aspire.Hosting.Azure/AcrLoginService.cs | 73 +- .../AzureBicepResource.cs | 4 +- .../AzureEnvironmentResource.cs | 4 +- .../AzureEnvironmentResourceExtensions.cs | 49 +- .../AzureProvisioningController.cs | 3188 ++++++++++++ .../AzureProvisioningJsonHelpers.cs | 37 + .../AzureResourcePreparer.cs | 76 + .../AzureProvisionerExtensions.cs | 11 +- .../Provisioning/BicepUtilities.cs | 53 +- .../BaseProvisioningContextProvider.cs | 83 +- .../Provisioning/Internal/BicepCompiler.cs | 23 +- .../Internal/DefaultArmClientProvider.cs | 41 + .../DefaultArmDeploymentCollection.cs | 6 + .../Internal/DefaultResourceGroupResource.cs | 7 +- .../Internal/IProvisioningServices.cs | 76 +- .../RunModeProvisioningContextProvider.cs | 552 +- .../Provisioners/AzureProvisioner.cs | 273 +- .../Provisioners/BicepProvisioner.cs | 336 +- .../Provisioners/IBicepProvisioner.cs | 7 +- .../AzureProvisioningStrings.Designer.cs | 364 +- .../Resources/AzureProvisioningStrings.resx | 122 +- .../xlf/AzureProvisioningStrings.cs.xlf | 200 + .../xlf/AzureProvisioningStrings.de.xlf | 200 + .../xlf/AzureProvisioningStrings.es.xlf | 200 + .../xlf/AzureProvisioningStrings.fr.xlf | 200 + .../xlf/AzureProvisioningStrings.it.xlf | 200 + .../xlf/AzureProvisioningStrings.ja.xlf | 200 + .../xlf/AzureProvisioningStrings.ko.xlf | 200 + .../xlf/AzureProvisioningStrings.pl.xlf | 200 + .../xlf/AzureProvisioningStrings.pt-BR.xlf | 200 + .../xlf/AzureProvisioningStrings.ru.xlf | 200 + .../xlf/AzureProvisioningStrings.tr.xlf | 200 + .../xlf/AzureProvisioningStrings.zh-Hans.xlf | 200 + .../xlf/AzureProvisioningStrings.zh-Hant.xlf | 200 + .../AuxiliaryBackchannelRpcTarget.cs | 5 +- .../Dashboard/DashboardServiceData.cs | 5 + .../Model/InputViewModelTests.cs | 78 + .../AzureStorageRunModeTests.cs | 258 + .../AcrLoginServiceTests.cs | 216 + .../AzureBicepProvisionerTests.cs | 698 +++ .../AzureDeployerTests.cs | 5 +- ...AzureEnvironmentResourceExtensionsTests.cs | 4476 ++++++++++++++++- .../ProvisioningContextProviderTests.cs | 74 + .../ProvisioningTestHelpers.cs | 156 +- ..._DoesNotHang_step=diagnostics.verified.txt | 44 +- ...ps_CreatesCorrectDependencies.verified.txt | 44 +- ...nments_Works_step=diagnostics.verified.txt | 44 +- ...ts_CreatesCorrectDependencies.verified.txt | 44 +- ...on_CreatesCorrectDependencies.verified.txt | 44 +- ...meterTypesFromDeploymentState.verified.txt | 4 + ...ploymentState_FirstDeployment.verified.txt | 2 + .../AuxiliaryBackchannelRpcTargetTests.cs | 36 + 53 files changed, 13498 insertions(+), 734 deletions(-) create mode 100644 src/Aspire.Hosting.Azure/AzureProvisioningController.cs create mode 100644 src/Aspire.Hosting.Azure/AzureProvisioningJsonHelpers.cs create mode 100644 tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageRunModeTests.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AcrLoginServiceTests.cs diff --git a/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs b/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs index acb7a2b9d81..b411bd363ea 100644 --- a/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs +++ b/src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs @@ -17,6 +17,10 @@ public InputViewModel(InteractionInput input) public void SetInput(InteractionInput input) { + // Interaction updates carry a full server-side snapshot even when only one input changed. Keep + // local values by default so an update for a dependent choice does not clobber text the user is + // typing elsewhere in the dialog. ShouldUseIncomingValue captures the cases where the server is + // authoritative because the field is being dynamically loaded or is not currently editable. var value = Input is null || ShouldUseIncomingValue(Input, input) ? input.Value : Input.Value; @@ -125,8 +129,12 @@ private static bool OptionsEqual(List> existing, List enabled transitions, such as + // Azure Subscription ID becoming editable after tenant-specific subscriptions are loaded. + return (current.Loading && !incoming.Loading) || current.Disabled || incoming.Disabled; } } diff --git a/src/Aspire.Hosting.Azure/AcrLoginService.cs b/src/Aspire.Hosting.Azure/AcrLoginService.cs index bf943179647..6a4e1e4ad01 100644 --- a/src/Aspire.Hosting.Azure/AcrLoginService.cs +++ b/src/Aspire.Hosting.Azure/AcrLoginService.cs @@ -3,6 +3,7 @@ #pragma warning disable ASPIRECONTAINERRUNTIME001 +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; using Aspire.Hosting.Publishing; @@ -19,6 +20,11 @@ internal sealed class AcrLoginService : IAcrLoginService { private const string AcrUsername = "00000000-0000-0000-0000-000000000000"; private const string AcrScope = "https://containerregistry.azure.net/.default"; + // Thirty attempts at a two-second cadence gives new registries about a minute + // for DNS, data-plane, and RBAC propagation without blocking deployment for too long. + private const int MaxLoginAttempts = 30; + private static readonly TimeSpan s_loginRetryDelay = TimeSpan.FromSeconds(2); + private static readonly TimeSpan s_maxLoginRetryDuration = TimeSpan.FromMinutes(1); private static readonly JsonSerializerOptions s_jsonOptions = new(JsonSerializerDefaults.Web) { @@ -28,6 +34,7 @@ internal sealed class AcrLoginService : IAcrLoginService private readonly IHttpClientFactory _httpClientFactory; private readonly IContainerRuntimeResolver _containerRuntimeResolver; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private sealed class AcrRefreshTokenResponse { @@ -44,11 +51,17 @@ private sealed class AcrRefreshTokenResponse /// The HTTP client factory for making OAuth2 exchange requests. /// The container runtime resolver for performing registry login. /// The logger for diagnostic output. - public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntimeResolver containerRuntimeResolver, ILogger logger) + /// The time provider used for retry delays. + public AcrLoginService( + IHttpClientFactory httpClientFactory, + IContainerRuntimeResolver containerRuntimeResolver, + ILogger logger, + TimeProvider timeProvider) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _containerRuntimeResolver = containerRuntimeResolver ?? throw new ArgumentNullException(nameof(containerRuntimeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } /// @@ -69,15 +82,59 @@ public async Task LoginAsync( _logger.LogDebug("AAD access token acquired for ACR audience, registry: {RegistryEndpoint}, token length: {TokenLength}", registryEndpoint, aadToken.Token.Length); - // Step 2: Exchange AAD token for ACR refresh token - var refreshToken = await ExchangeAadTokenForAcrRefreshTokenAsync( - registryEndpoint, tenantId, aadToken.Token, cancellationToken).ConfigureAwait(false); + var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); + var retryStartTimestamp = _timeProvider.GetTimestamp(); - _logger.LogDebug("ACR refresh token acquired, length: {TokenLength}", refreshToken.Length); + for (var attempt = 1; ; attempt++) + { + try + { + // Step 2: Exchange AAD token for ACR refresh token + var refreshToken = await ExchangeAadTokenForAcrRefreshTokenAsync( + registryEndpoint, tenantId, aadToken.Token, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("ACR refresh token acquired, length: {TokenLength}", refreshToken.Length); + + // Step 3: Login to the registry using container runtime + await containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false); + return; + } + catch (HttpRequestException ex) when (ShouldRetryAcrLoginFailure(ex, attempt, retryStartTimestamp)) + { + // New registries can briefly fail DNS resolution or reject token exchange while + // data-plane endpoint and RBAC propagation catch up with ARM deployment success. + _logger.LogWarning( + ex, + "ACR login to {RegistryEndpoint} failed on attempt {Attempt} of {MaxAttempts}. Retrying in {RetryDelay}.", + registryEndpoint, + attempt, + MaxLoginAttempts, + s_loginRetryDelay); + await Task.Delay(s_loginRetryDelay, _timeProvider, cancellationToken).ConfigureAwait(false); + } + } + } - // Step 3: Login to the registry using container runtime - var containerRuntime = await _containerRuntimeResolver.ResolveAsync(cancellationToken).ConfigureAwait(false); - await containerRuntime.LoginToRegistryAsync(registryEndpoint, AcrUsername, refreshToken, cancellationToken).ConfigureAwait(false); + private bool ShouldRetryAcrLoginFailure(HttpRequestException ex, int attempt, long retryStartTimestamp) + { + return attempt < MaxLoginAttempts && + _timeProvider.GetElapsedTime(retryStartTimestamp) < s_maxLoginRetryDuration && + IsRetryableAcrLoginFailure(ex); + } + + private static bool IsRetryableAcrLoginFailure(HttpRequestException ex) + { + if (ex.StatusCode is null) + { + return true; + } + + return ex.StatusCode is HttpStatusCode.RequestTimeout or + HttpStatusCode.TooManyRequests or + HttpStatusCode.Unauthorized or + HttpStatusCode.Forbidden or + HttpStatusCode.NotFound || + (int)ex.StatusCode >= 500; } private async Task ExchangeAadTokenForAcrRefreshTokenAsync( diff --git a/src/Aspire.Hosting.Azure/AzureBicepResource.cs b/src/Aspire.Hosting.Azure/AzureBicepResource.cs index 78dc2263ece..6133d7be830 100644 --- a/src/Aspire.Hosting.Azure/AzureBicepResource.cs +++ b/src/Aspire.Hosting.Azure/AzureBicepResource.cs @@ -14,7 +14,6 @@ using Aspire.Hosting.Pipelines; using Aspire.Hosting.Publishing; using Azure; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -306,7 +305,6 @@ private static async Task ProvisionAzureBicepResourceAsync(PipelineStepContext c } var bicepProvisioner = context.Services.GetRequiredService(); - var configuration = context.Services.GetRequiredService(); // Find the AzureEnvironmentResource from the application model var azureEnvironment = context.Model.Resources.OfType().FirstOrDefault(); @@ -326,7 +324,7 @@ private static async Task ProvisionAzureBicepResourceAsync(PipelineStepContext c try { if (await bicepProvisioner.ConfigureResourceAsync( - configuration, resource, context.CancellationToken).ConfigureAwait(false)) + resource, context.CancellationToken).ConfigureAwait(false)) { resource.ProvisioningTaskCompletionSource?.TrySetResult(); await resourceTask.CompleteAsync( diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs index 86e7fdda2c9..49a7d51bf40 100644 --- a/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs +++ b/src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs @@ -41,6 +41,8 @@ public sealed class AzureEnvironmentResource : Resource /// public const string ProvisionInfrastructureStepName = "provision-azure-bicep-resources"; + internal const string RunModeProvisionStepName = "run-mode-azure-provision"; + /// /// Gets or sets the Azure location that the resources will be deployed to. /// @@ -94,7 +96,7 @@ public AzureEnvironmentResource(string name, ParameterResource location, Paramet { var runModeProvisionStep = new PipelineStep { - Name = "run-mode-azure-provision", + Name = RunModeProvisionStepName, Description = $"Provisions the Azure resources for {Name}.", Action = static async context => { diff --git a/src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs b/src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs index cd14392fc73..2e98c366da7 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; @@ -33,8 +37,40 @@ public static IResourceBuilder AddAzureEnvironment(thi var principalId = ParameterResourceBuilderExtensions.CreateParameter(builder, "principalId", false); var resource = new AzureEnvironmentResource(resourceName, locationParam, resourceGroupName, principalId); + if (builder.ExecutionContext.IsRunMode) + { + var resourceBuilder = builder.AddResource(resource) + .WithInitialState(new CustomResourceSnapshot + { + ResourceType = nameof(AzureEnvironmentResource), + CreationTimeStamp = DateTime.UtcNow, + State = KnownResourceStates.NotStarted, + Properties = ImmutableArray.Empty + }); - // Add the resource to the application model + foreach (var command in AzureProvisioningController.EnvironmentCommandDefinitions) + { + resourceBuilder.WithCommand( + command.Name, + command.DisplayName, + executeCommand: context => command.ExecuteCommand(context.Services.GetRequiredService(), context), + commandOptions: new CommandOptions + { + Description = command.Description, + ConfirmationMessage = command.ConfirmationMessage, + IconName = command.IconName, + IconVariant = command.IconVariant, + IsHighlighted = command.IsHighlighted, + Arguments = command.Arguments ?? [], + ValidateArguments = command.ValidateArguments, + UpdateState = context => context.Services.GetRequiredService().GetEnvironmentCommandState() + }); + } + + return resourceBuilder.ExcludeFromManifest(); + } + + // In publish mode, add the resource to the application model // but exclude it from the manifest so that it is not treated // as a publishable resource by components that process the manifest // for elements. @@ -103,8 +139,13 @@ public static IResourceBuilder WithResourceGroup( private static string CreateDefaultAzureEnvironmentName(this IDistributedApplicationBuilder builder) { - // Use ProjectNameSha256 for stable naming across deployments - var applicationHash = builder.Configuration["AppHost:ProjectNameSha256"]?[..5].ToLowerInvariant(); - return $"azure{applicationHash}"; + var name = "azure-environment"; + var index = 2; + while (builder.Resources.Any(resource => StringComparers.ResourceName.Equals(resource.Name, name))) + { + name = $"azure-environment{index++}"; + } + + return name; } } diff --git a/src/Aspire.Hosting.Azure/AzureProvisioningController.cs b/src/Aspire.Hosting.Azure/AzureProvisioningController.cs new file mode 100644 index 00000000000..86583285413 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzureProvisioningController.cs @@ -0,0 +1,3188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIREAZURE001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREPIPELINES002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Aspire.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 Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aspire.Hosting.Azure; + +/// +/// 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, or +/// CancelProvisioning — the only three completion paths — so dependent resources unblock with the prerequisite's +/// result as soon as it finishes, not when the entire batch completes. +/// +/// +/// The controller tracks lightweight in-memory state (AzureControllerState) under a lock. This state drives +/// command enablement in the dashboard (commands are disabled while an operation targeting the same resources +/// is running). Azure identity properties shown on the AzureEnvironmentResource are read from persisted +/// context when the environment state is published. +/// +/// +/// 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 periodically. 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 GetAzureResourceCommandName = "get-azure-resource"; + internal const string CancelDeploymentCommandName = "cancel-deployment"; + internal const string DeleteAzureResourceCommandName = "delete-azure-resource"; + internal const string ReprovisionResourceCommandName = "reprovision"; + internal const string ResetProvisioningStateCommandName = "reset-provisioning-state"; + internal const string ChangeAzureContextCommandName = "change-azure-context"; + internal const string ReprovisionAllCommandName = "reprovision-all"; + internal const string DeleteAzureResourcesCommandName = "delete-azure-resources"; + internal const string LocationOverrideKey = "LocationOverride"; + internal const string MissingInAzureState = "Missing in Azure"; + internal const string DriftedState = "Drifted"; + internal const string CreatingArmDeploymentState = "Creating ARM Deployment"; + internal const string WaitingForDeploymentState = "Waiting for Deployment"; + private const string SubscriptionIdArgumentName = "subscriptionId"; + private const string ResourceGroupArgumentName = "resourceGroup"; + private const string LocationArgumentName = "location"; + private const string TenantIdArgumentName = "tenantId"; + + private static readonly string[] s_resettableProperties = + [ + "azure.subscription.id", + "azure.resource.group", + "azure.tenant.domain", + "azure.tenant.id", + "azure.location", + 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); + + // The dashboard uses declaration order when rendering commands. Highlighted commands are promoted ahead of + // the remaining command list, so keep each definition list ordered by the desired fallback display order. + internal static ImmutableArray EnvironmentCommandDefinitions { get; } = + [ + new( + AzureEnvironmentCommand.ResetProvisioningState, + ResetProvisioningStateCommandName, + AzureProvisioningStrings.ResetProvisioningStateCommandName, + AzureProvisioningStrings.ResetProvisioningStateCommandDescription, + AzureProvisioningStrings.ResetProvisioningStateCommandConfirmation, + "ArrowSync", + IconVariant.Regular, + IsHighlighted: true, + ExecuteCommand: static (controller, context) => controller.ExecuteResetProvisioningStateCommandAsync(context)), + new( + AzureEnvironmentCommand.ChangeAzureContext, + ChangeAzureContextCommandName, + AzureProvisioningStrings.ChangeAzureContextCommandName, + AzureProvisioningStrings.ChangeAzureContextCommandDescription, + AzureProvisioningStrings.ChangeAzureContextCommandConfirmation, + "Edit", + IconVariant.Regular, + IsHighlighted: true, + ExecuteCommand: static (controller, context) => controller.ExecuteChangeAzureContextCommandAsync(context), + Arguments: CreateAzureContextCommandArguments(), + ValidateArguments: ValidateAzureContextCommandArguments), + new( + AzureEnvironmentCommand.ReprovisionAll, + ReprovisionAllCommandName, + AzureProvisioningStrings.ReprovisionAllCommandName, + AzureProvisioningStrings.ReprovisionAllCommandDescription, + AzureProvisioningStrings.ReprovisionAllCommandConfirmation, + "ArrowSync", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, context) => controller.ExecuteReprovisionAllCommandAsync(context)), + new( + AzureEnvironmentCommand.DeleteAzureResources, + DeleteAzureResourcesCommandName, + AzureProvisioningStrings.DeleteAzureResourcesCommandName, + AzureProvisioningStrings.DeleteAzureResourcesCommandDescription, + AzureProvisioningStrings.DeleteAzureResourcesCommandConfirmation, + "Delete", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, context) => controller.ExecuteDeleteAzureResourcesCommandAsync(context)) + ]; + + // Keep this in the desired dashboard command order; see EnvironmentCommandDefinitions for ordering rules. + internal static ImmutableArray ResourceCommandDefinitions { get; } = + [ + new( + AzureResourceCommand.ChangeLocation, + ChangeResourceLocationCommandName, + AzureProvisioningStrings.ChangeResourceLocationCommandName, + AzureProvisioningStrings.ChangeResourceLocationCommandDescription, + ConfirmationMessage: null, + "Location", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteChangeResourceLocationCommandAsync(resourceName, context), + Arguments: CreateChangeLocationCommandArguments(deploymentStateResourceName: null)), + new( + AzureResourceCommand.GetAzureResource, + GetAzureResourceCommandName, + AzureProvisioningStrings.GetAzureResourceCommandName, + AzureProvisioningStrings.GetAzureResourceCommandDescription, + ConfirmationMessage: null, + "Info", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteGetAzureResourceCommandAsync(resourceName, context)), + new( + AzureResourceCommand.CancelDeployment, + CancelDeploymentCommandName, + AzureProvisioningStrings.CancelDeploymentCommandName, + AzureProvisioningStrings.CancelDeploymentCommandDescription, + AzureProvisioningStrings.CancelDeploymentCommandConfirmation, + "Stop", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteCancelResourceDeploymentCommandAsync(resourceName, context)), + new( + AzureResourceCommand.DeleteAzureResource, + DeleteAzureResourceCommandName, + AzureProvisioningStrings.DeleteAzureResourceCommandName, + AzureProvisioningStrings.DeleteAzureResourceCommandDescription, + AzureProvisioningStrings.DeleteAzureResourceCommandConfirmation, + "Delete", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteDeleteAzureResourceCommandAsync(resourceName, context)), + new( + AzureResourceCommand.ForgetState, + ForgetStateCommandName, + AzureProvisioningStrings.ForgetStateCommandName, + AzureProvisioningStrings.ForgetStateCommandDescription, + AzureProvisioningStrings.ForgetStateCommandConfirmation, + "ArrowReset", + IconVariant.Regular, + IsHighlighted: false, + ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteForgetStateCommandAsync(resourceName, context)), + new( + AzureResourceCommand.Reprovision, + ReprovisionResourceCommandName, + AzureProvisioningStrings.ReprovisionResourceCommandName, + AzureProvisioningStrings.ReprovisionResourceCommandDescription, + AzureProvisioningStrings.ReprovisionResourceCommandConfirmation, + "ArrowSync", + IconVariant.Regular, + IsHighlighted: true, + ExecuteCommand: static (controller, resourceName, context) => controller.ExecuteReprovisionResourceCommandAsync(resourceName, context)) + ]; + + // The Change Azure context dialog is a cascade: + // + // Tenant ID -> Subscription ID -> Resource group -> Location + // + // The dashboard sends update requests when a user edits a source input, and the + // hosting interaction service then reloads any inputs that list that source in + // DependsOnInputs. That dependency mechanism does not fire for values populated by + // startup loads, configuration, or the current Azure context. Any downstream input + // that must be usable immediately for an already-known upstream value must also opt + // into AlwaysLoadOnStart. + private static IReadOnlyList CreateAzureContextCommandArguments() => + [ + new() + { + Name = TenantIdArgumentName, + Label = AzureProvisioningStrings.TenantLabel, + Placeholder = AzureProvisioningStrings.TenantPlaceholder, + InputType = InputType.Choice, + Required = true, + AllowCustomChoice = true, + DynamicLoading = new InputLoadOptions + { + // Tenant ID has no dependencies, so startup loading is the only opportunity to + // populate the current tenant and enumerate available tenants before the user edits + // anything in the dialog. + AlwaysLoadOnStart = true, + LoadCallback = LoadTenantArgumentOptionsAsync + } + }, + new() + { + Name = SubscriptionIdArgumentName, + Label = AzureProvisioningStrings.SubscriptionIdLabel, + Placeholder = AzureProvisioningStrings.SubscriptionIdPlaceholder, + InputType = InputType.Choice, + Required = true, + AllowCustomChoice = true, + // Keep the subscription control unavailable until the load callback resolves the + // selected tenant. This avoids accepting a stale subscription value from a previous + // tenant while the dashboard is still waiting for tenant-scoped subscription options. + Disabled = true, + DynamicLoading = new InputLoadOptions + { + // Startup loading handles a tenant preselected from configuration/current context, + // not from a user edit, and then re-enables this input. + AlwaysLoadOnStart = true, + LoadCallback = LoadSubscriptionArgumentOptionsAsync, + DependsOnInputs = [TenantIdArgumentName] + } + }, + new() + { + Name = ResourceGroupArgumentName, + Label = AzureProvisioningStrings.ResourceGroupLabel, + Placeholder = AzureProvisioningStrings.ResourceGroupPlaceholder, + InputType = InputType.Choice, + Required = true, + AllowCustomChoice = true, + DynamicLoading = new InputLoadOptions + { + // Startup loading handles a subscription preselected from configuration/current + // context; dependency updates handle later Subscription ID edits. + AlwaysLoadOnStart = true, + LoadCallback = LoadResourceGroupArgumentOptionsAsync, + DependsOnInputs = [SubscriptionIdArgumentName] + } + }, + new() + { + Name = LocationArgumentName, + Label = AzureProvisioningStrings.LocationLabel, + Placeholder = AzureProvisioningStrings.LocationPlaceholder, + InputType = InputType.Choice, + Required = true, + AllowCustomChoice = true, + DynamicLoading = new InputLoadOptions + { + // Startup loading reflects a known resource-group location immediately; dependency + // updates handle later edits to either upstream input. + AlwaysLoadOnStart = true, + LoadCallback = context => LoadLocationArgumentOptionsAsync(context), + DependsOnInputs = [SubscriptionIdArgumentName, ResourceGroupArgumentName] + } + } + ]; + + internal static IReadOnlyList CreateChangeLocationCommandArguments(string? deploymentStateResourceName) => + [ + new() + { + Name = LocationArgumentName, + Label = AzureProvisioningStrings.LocationLabel, + Placeholder = AzureProvisioningStrings.LocationPlaceholder, + InputType = InputType.Choice, + Required = true, + AllowCustomChoice = true, + DynamicLoading = new InputLoadOptions + { + AlwaysLoadOnStart = true, + LoadCallback = context => LoadLocationArgumentOptionsAsync(context, deploymentStateResourceName) + } + } + ]; + + private static async Task LoadTenantArgumentOptionsAsync(LoadInputContext context) + { + var controller = context.Services.GetRequiredService(); + var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false); + + // Preserve a value the user has already typed or selected. Dynamic loading can run again + // after dashboard updates, and replacing a non-empty value here would undo user intent. + if (string.IsNullOrEmpty(context.Input.Value)) + { + context.Input.Value = currentContext.TenantId; + } + + var tenantOptions = await controller.GetTenantOptionsAsync(context.CancellationToken).ConfigureAwait(false); + if (tenantOptions.Count > 0) + { + context.Input.Options = tenantOptions; + } + } + + private static async Task LoadSubscriptionArgumentOptionsAsync(LoadInputContext context) + { + var controller = context.Services.GetRequiredService(); + var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false); + var tenantId = context.AllInputs.TryGetByName(TenantIdArgumentName, out var tenantInput) && !string.IsNullOrWhiteSpace(tenantInput.Value) + ? tenantInput.Value + : currentContext.TenantId; + + // Seed with the current subscription so the common case is preselected. If the user picked + // a different tenant and this subscription is not in the loaded option list, the interaction + // loading pipeline clears the invalid value for non-custom choices. This input allows custom + // choices, so users can still paste a subscription that enumeration did not return. + if (string.IsNullOrEmpty(context.Input.Value)) + { + context.Input.Value = currentContext.SubscriptionId; + } + + var subscriptionOptions = await controller.GetSubscriptionOptionsAsync(tenantId, context.CancellationToken).ConfigureAwait(false); + if (subscriptionOptions.Count > 0) + { + context.Input.Options = subscriptionOptions; + } + + // The control starts disabled because it is tenant-scoped. Re-enable it even if enumeration + // returns no options; AllowCustomChoice lets the user enter a subscription ID manually. + context.Input.Disabled = false; + } + + private static async Task LoadResourceGroupArgumentOptionsAsync(LoadInputContext context) + { + var controller = context.Services.GetRequiredService(); + var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false); + var subscriptionId = context.AllInputs.TryGetByName(SubscriptionIdArgumentName, out var subscriptionInput) && !string.IsNullOrWhiteSpace(subscriptionInput.Value) + ? subscriptionInput.Value + : currentContext.SubscriptionId; + + // Keep the persisted/current resource group as the default unless the user has already + // provided a value in the open dialog. + if (string.IsNullOrEmpty(context.Input.Value)) + { + context.Input.Value = currentContext.ResourceGroup; + } + + var resourceGroupOptions = await controller.GetResourceGroupOptionsAsync(subscriptionId, context.CancellationToken).ConfigureAwait(false); + context.Input.Options = resourceGroupOptions.Select(static rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList(); + // A user can create or target a resource group that is not returned by enumeration, so an + // empty option list should still leave the input editable. + context.Input.Disabled = false; + } + + private static async Task LoadLocationArgumentOptionsAsync(LoadInputContext context, string? deploymentStateResourceName = null) + { + var controller = context.Services.GetRequiredService(); + var currentContext = await controller.GetCurrentAzureContextAsync(context.CancellationToken).ConfigureAwait(false); + var subscriptionId = context.AllInputs.TryGetByName(SubscriptionIdArgumentName, out var subscriptionInput) && !string.IsNullOrWhiteSpace(subscriptionInput.Value) + ? subscriptionInput.Value + : currentContext.SubscriptionId; + var resourceGroupName = context.AllInputs.TryGetByName(ResourceGroupArgumentName, out var resourceGroupInput) && !string.IsNullOrWhiteSpace(resourceGroupInput.Value) + ? resourceGroupInput.Value + : null; + + if (!string.IsNullOrWhiteSpace(resourceGroupName)) + { + var resourceGroupOptions = await controller.GetResourceGroupOptionsAsync(subscriptionId, context.CancellationToken).ConfigureAwait(false); + var (_, resourceGroupLocation) = resourceGroupOptions.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrEmpty(resourceGroupLocation)) + { + // Existing resource groups are pinned to a single Azure region. When the selected + // group is known, constrain the location input to that region instead of letting the + // user pick a value that ARM will reject for deployments into that group. + context.Input.Options = [KeyValuePair.Create(resourceGroupLocation, resourceGroupLocation)]; + context.Input.Value = resourceGroupLocation; + context.Input.Disabled = true; + return; + } + } + + // For Change resource location, deploymentStateResourceName identifies the specific resource + // whose effective location should be shown. For Change Azure context, there is no per-resource + // target, so the current environment location is the fallback. + if (string.IsNullOrEmpty(context.Input.Value)) + { + context.Input.Value = !string.IsNullOrEmpty(deploymentStateResourceName) + ? await controller.GetEffectiveResourceLocationAsync(deploymentStateResourceName, context.CancellationToken).ConfigureAwait(false) + : currentContext.Location; + } + + var locationOptions = await controller.GetLocationOptionsAsync(subscriptionId, context.CancellationToken).ConfigureAwait(false); + if (locationOptions.Count == 0) + { + return; + } + + context.Input.Options = locationOptions; + // The input is disabled only when an existing resource group determines the location. If we + // got here, either no resource group is selected or the group location is unknown, so the + // user must be able to choose or type a location. + context.Input.Disabled = false; + } + + private static Task ValidateAzureContextCommandArguments(InputsDialogValidationContext validationContext) + { + ValidateGuidArgument(validationContext, SubscriptionIdArgumentName, AzureProvisioningStrings.ValidationSubscriptionIdInvalid); + ValidateGuidArgument(validationContext, TenantIdArgumentName, AzureProvisioningStrings.ValidationTenantIdInvalid); + + var resourceGroupInput = validationContext.Inputs[ResourceGroupArgumentName]; + if (!BaseProvisioningContextProvider.IsValidResourceGroupName(resourceGroupInput.Value)) + { + validationContext.AddValidationError(resourceGroupInput, AzureProvisioningStrings.ValidationResourceGroupNameInvalid); + } + + return Task.CompletedTask; + } + + private static void ValidateGuidArgument(InputsDialogValidationContext validationContext, string inputName, string validationMessage) + { + var input = validationContext.Inputs[inputName]; + if (!string.IsNullOrWhiteSpace(input.Value) && !Guid.TryParse(input.Value, out _)) + { + validationContext.AddValidationError(input, validationMessage); + } + } + + public async Task ResetStateAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + await RunOperationAsync(model, new ResetStateIntent(), 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(Options: null), cancellationToken).ConfigureAwait(false); + } + + private async Task ChangeAzureContextAsync(DistributedApplicationModel model, AzureProvisioningOptionsUpdate options, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(options); + + return await RunOperationAsync(model, new ChangeAzureContextIntent(options), cancellationToken).ConfigureAwait(false); + } + + public Task EnsureProvisionedAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + + return RunOperationAsync(model, new EnsureProvisionedIntent(), cancellationToken); + } + + public async Task 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) + { + // Drift intents use AzureOperationState.None so they never set CurrentIntent; this flag + // dedupes queued/running drift checks without disabling dashboard commands. + if (_state.Status.CurrentIntent is not null || _driftCheckQueued) + { + return; + } + + _driftCheckQueued = true; + } + + try + { + await QueueAndWaitForOperationAsync(model, new DetectDriftIntent(), cancellationToken).ConfigureAwait(false); + } + catch + { + lock (_operationStateLock) + { + _driftCheckQueued = false; + } + + throw; + } + } + + public async Task 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 CancelResourceDeploymentAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + await RunOperationAsync(model, new CancelResourceDeploymentIntent(resourceName), cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteAzureResourceAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + + await RunOperationAsync(model, new DeleteAzureResourceIntent(resourceName), cancellationToken).ConfigureAwait(false); + } + + public async Task 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 targetResources = GetTargetAzureResources(model, resourceName); + var currentLocation = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResources[0]), cancellationToken).ConfigureAwait(false); + var locationOptions = await GetLocationOptionsAsync(cancellationToken).ConfigureAwait(false); + var useChoiceInput = locationOptions.Count > 0; + + var result = await interactionService.PromptInputsAsync( + AzureProvisioningStrings.ChangeResourceLocationPromptTitle, + string.Format(CultureInfo.CurrentCulture, AzureProvisioningStrings.ChangeResourceLocationPromptMessage, resourceName), + [ + new InteractionInput + { + Name = AzureBicepResource.KnownParameters.Location, + Label = AzureProvisioningStrings.LocationLabel, + Placeholder = AzureProvisioningStrings.LocationPlaceholder, + InputType = useChoiceInput ? InputType.Choice : InputType.Text, + AllowCustomChoice = true, + Required = true, + Value = currentLocation, + Options = locationOptions + } + ], + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.Canceled) + { + return false; + } + + var location = result.Data[AzureBicepResource.KnownParameters.Location].Value; + if (string.IsNullOrWhiteSpace(location)) + { + return false; + } + + return await ChangeResourceLocationAsync(model, resourceName, location, cancellationToken).ConfigureAwait(false); + } + + private async Task ChangeResourceLocationAsync(DistributedApplicationModel model, string resourceName, string location, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + + location = NormalizeLocation(location, await GetLocationOptionsAsync(cancellationToken).ConfigureAwait(false)); + + return await RunOperationAsync(model, new ChangeResourceLocationIntent(resourceName, location), cancellationToken).ConfigureAwait(false); + } + + private Task ExecuteResetProvisioningStateCommandAsync(ExecuteCommandContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => ResetStateAsync(model, context.CancellationToken), + AzureProvisioningStrings.ResetProvisioningStateCommandSuccess, + () => CreateEnvironmentCommandResultDataAsync(ResetProvisioningStateCommandName, model, context.CancellationToken)); + } + + private Task ExecuteChangeAzureContextCommandAsync(ExecuteCommandContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => ChangeAzureContextCommandAsync(model, context.Arguments, context.CancellationToken), + AzureProvisioningStrings.ChangeAzureContextCommandSuccess, + () => CreateEnvironmentCommandResultDataAsync(ChangeAzureContextCommandName, model, context.CancellationToken)); + } + + private Task ExecuteReprovisionAllCommandAsync(ExecuteCommandContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => ReprovisionAllAsync(model, context.CancellationToken), + AzureProvisioningStrings.ReprovisionAllCommandSuccess, + () => CreateEnvironmentCommandResultDataAsync(ReprovisionAllCommandName, model, context.CancellationToken)); + } + + private Task ExecuteDeleteAzureResourcesCommandAsync(ExecuteCommandContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => DeleteAzureResourcesAsync(model, context.CancellationToken), + AzureProvisioningStrings.DeleteAzureResourcesCommandSuccess, + () => CreateEnvironmentCommandResultDataAsync(DeleteAzureResourcesCommandName, model, context.CancellationToken)); + } + + private Task ExecuteChangeResourceLocationCommandAsync(string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => ChangeResourceLocationCommandAsync(model, resourceName, context.Arguments, context.CancellationToken), + AzureProvisioningStrings.ChangeResourceLocationCommandSuccess, + () => CreateResourceCommandResultDataAsync(ChangeResourceLocationCommandName, model, resourceName, context.CancellationToken)); + } + + private Task ExecuteGetAzureResourceCommandAsync(string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => Task.CompletedTask, + AzureProvisioningStrings.GetAzureResourceCommandSuccess, + () => CreateAzureResourceInfoCommandResultDataAsync(model, resourceName, context.CancellationToken)); + } + + private Task ExecuteCancelResourceDeploymentCommandAsync(string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => CancelResourceDeploymentAsync(model, resourceName, context.CancellationToken), + AzureProvisioningStrings.CancelDeploymentCommandSuccess, + () => CreateResourceCommandResultDataAsync(CancelDeploymentCommandName, model, resourceName, context.CancellationToken)); + } + + private Task ExecuteDeleteAzureResourceCommandAsync(string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => RunOperationAsync(model, new DeleteAzureResourceIntent(resourceName), context.CancellationToken), + AzureProvisioningStrings.DeleteAzureResourceCommandSuccess, + result => CreateDeleteAzureResourceCommandResultDataAsync(model, resourceName, result, context.CancellationToken)); + } + + private Task ExecuteForgetStateCommandAsync(string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => ForgetResourceStateAsync(model, resourceName, context.CancellationToken), + AzureProvisioningStrings.ForgetStateCommandSuccess, + () => CreateResourceCommandResultDataAsync(ForgetStateCommandName, model, resourceName, context.CancellationToken)); + } + + private Task ExecuteReprovisionResourceCommandAsync(string resourceName, ExecuteCommandContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + var model = context.Services.GetRequiredService(); + + return ExecuteCommandAsync( + () => ReprovisionResourceAsync(model, resourceName, context.CancellationToken), + AzureProvisioningStrings.ReprovisionResourceCommandSuccess, + () => CreateResourceCommandResultDataAsync(ReprovisionResourceCommandName, model, resourceName, context.CancellationToken)); + } + + private async Task ChangeAzureContextCommandAsync(DistributedApplicationModel model, InteractionInputCollection arguments, CancellationToken cancellationToken) + { + if (arguments.Count == 0) + { + // Resource command execution can still invoke the command without dashboard-provided + // arguments. In that case fall back to the provisioning options manager prompt path, + // which is also used outside the dashboard command argument flow. + return await ChangeAzureContextAsync(model, cancellationToken).ConfigureAwait(false); + } + + var location = arguments.GetString(LocationArgumentName); + if (!string.IsNullOrWhiteSpace(location)) + { + // Users can type either a display name or a canonical Azure location. Normalize before + // persisting so future provisioning compares and reapplies a stable location value. + location = NormalizeLocation(location, await GetLocationOptionsAsync(arguments.GetString(SubscriptionIdArgumentName), cancellationToken).ConfigureAwait(false)); + } + + // Convert the dialog inputs into the persisted provisioning options shape. Tenant ID was + // added after the original subscription/resource-group/location options, so keep the lookup + // tolerant in case an older caller submits the smaller argument set. + var options = new AzureProvisioningOptionsUpdate( + SubscriptionId: arguments.GetString(SubscriptionIdArgumentName), + ResourceGroup: arguments.GetString(ResourceGroupArgumentName), + Location: location, + TenantId: arguments.TryGetByName(TenantIdArgumentName, out var tenantInput) ? tenantInput.Value : null); + + return await ChangeAzureContextAsync(model, options, cancellationToken).ConfigureAwait(false); + } + + private Task ChangeResourceLocationCommandAsync(DistributedApplicationModel model, string resourceName, InteractionInputCollection arguments, CancellationToken cancellationToken) + { + if (arguments.Count == 0) + { + // Commands invoked from non-dashboard surfaces may not include the pre-declared + // argument collection. Preserve the interactive prompt path for those callers instead + // of treating the command as malformed. + return ChangeResourceLocationAsync(model, resourceName, cancellationToken); + } + + var location = arguments.GetString(LocationArgumentName); + if (string.IsNullOrWhiteSpace(location)) + { + return Task.FromResult(false); + } + + return ChangeResourceLocationAsync(model, resourceName, location, cancellationToken); + } + + internal ResourceCommandState GetEnvironmentCommandState() + { + lock (_operationStateLock) + { + // Environment commands affect every provisionable Azure resource, so expose a simple + // global gate: while any user-visible Azure operation is running, prevent another one + // from entering the serialized queue. + return _state.Status.CurrentIntent is null ? ResourceCommandState.Enabled : ResourceCommandState.Disabled; + } + } + + internal ResourceCommandState GetResourceCommandState(string resourceName, AzureResourceCommand command, UpdateCommandStateContext context) + { + ArgumentException.ThrowIfNullOrEmpty(resourceName); + ArgumentNullException.ThrowIfNull(context); + + lock (_operationStateLock) + { + var currentOperation = _state.Status.CurrentIntent?.Operation; + if (currentOperation is not null) + { + if (command == AzureResourceCommand.GetAzureResource) + { + // The info command is read-only and useful while an operation is in flight, + // especially for debugging cached deployment state after a failure. + return ResourceCommandState.Enabled; + } + + var currentOperationAffectsResource = currentOperation.IsAllResources || currentOperation.ResourceNames.Contains(resourceName); + if (command == AzureResourceCommand.CancelDeployment) + { + // Cancellation is the only mutating command that can be enabled during another + // operation, and only for resources that are currently in a deployment state the + // provisioner can cancel. + return currentOperationAffectsResource && IsCancelableDeploymentState(context.ResourceSnapshot) + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled; + } + + return currentOperationAffectsResource + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled; + } + } + + return command == AzureResourceCommand.CancelDeployment && !IsCancelableDeploymentState(context.ResourceSnapshot) + ? ResourceCommandState.Disabled + : ResourceCommandState.Enabled; + } + + private static bool IsCancelableDeploymentState(CustomResourceSnapshot snapshot) + => snapshot.State?.Text is CreatingArmDeploymentState or WaitingForDeploymentState; + + private async Task RunOperationAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken) + { + _ = await QueueAndWaitForOperationAsync( + model, + intent, + cancellationToken).ConfigureAwait(false); + } + + private async Task RunOperationAsync(DistributedApplicationModel model, AzureIntent intent, CancellationToken cancellationToken) + { + return (T)(await QueueAndWaitForOperationAsync( + 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); + + // AfterProvisionAsync is responsible for publishing each resource's terminal state. + // Wait for those observers before publishing the aggregate environment state, but + // inspect the per-resource TCSs below so one failed observer does not hide others. + await Task.WhenAll(afterProvisionTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + cancellationToken.ThrowIfCancellationRequested(); + + var hasFailures = azureResources.Any(static resource => + resource.AzureResource.ProvisioningTaskCompletionSource?.Task is { IsFaulted: true } or { IsCanceled: true }); + + await PublishAzureEnvironmentStateAsync( + model, + hasFailures + ? new ResourceStateSnapshot("Failed to Provision", KnownResourceStateStyles.Error) + : new ResourceStateSnapshot("Running", KnownResourceStateStyles.Success), + cancellationToken).ConfigureAwait(false); + + return !hasFailures; + } + + private async Task EnsureProvisionedOrThrowAsync( + DistributedApplicationModel model, + IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources, + CancellationToken cancellationToken) + { + if (!await EnsureProvisionedCoreAsync(model, azureResources, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("Azure provisioning failed."); + } + + return true; + } + + private async Task ResetResourcesAsync( + DistributedApplicationModel model, + IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> azureResources, + bool preserveOverrides, + CancellationToken cancellationToken, + bool preserveInferredLocationOverrides = true) + { + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + // When preserving overrides, compare any per-resource location against the current + // environment location. A persisted value equal to the environment is treated as inferred + // state, not a user override, unless the caller explicitly asks to preserve inferred values. + var environmentLocation = preserveOverrides + ? (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).Location + : null; + + foreach (var resource in azureResources) + { + if (resource.AzureResource is not AzureBicepResource bicepResource) + { + continue; + } + + var currentLocationOverride = preserveOverrides && preserveInferredLocationOverrides + ? TryGetCurrentResourceLocationOverride(bicepResource, environmentLocation) + : null; + + if (currentLocationOverride is not null) + { + // Apply the override before clearing cached state so the next provisioning pass + // emits Bicep with the desired per-resource location. + bicepResource.Parameters[AzureBicepResource.KnownParameters.Location] = currentLocationOverride; + } + else if (!preserveOverrides || !preserveInferredLocationOverrides) + { + // Remove stale inferred parameters when the environment context changed. Leaving + // them in place would make a reset/reprovision keep deploying to the previous + // environment location. + bicepResource.Parameters.Remove(AzureBicepResource.KnownParameters.Location); + } + + await ClearCachedDeploymentStateAsync(bicepResource, preserveOverrides, environmentLocation, currentLocationOverride, preserveInferredLocationOverrides, cancellationToken).ConfigureAwait(false); + + // BicepResource outputs are cached in-memory as well as in deployment state. Clear both + // so connection strings and dependent resources do not observe values from a prior ARM + // deployment after a reset. + bicepResource.Outputs.Clear(); + bicepResource.SecretOutputs.Clear(); + + if (bicepResource is IAzureKeyVaultResource keyVaultResource) + { + // Key Vault secret resolution is tied to the old deployment outputs. Force it to be + // rebuilt after the resource is provisioned again. + keyVaultResource.SecretResolver = null; + } + + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = KnownResourceStates.NotStarted, + Properties = FilterProperties(state.Properties), + Urls = [], + CreationTimeStamp = null, + StartTimeStamp = null, + StopTimeStamp = null + }).ConfigureAwait(false); + } + } + + private async Task DeleteSectionAsync(string sectionName, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync(sectionName, cancellationToken).ConfigureAwait(false); + // Clear before DeleteSectionAsync so implementations that persist the same section instance + // do not accidentally keep stale values if deletion is implemented as a save/remove hybrid. + section.Data.Clear(); + await deploymentStateManager.DeleteSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + + private static List<(IResource Resource, IAzureResource AzureResource)> GetProvisionableAzureResources(DistributedApplicationModel model) + { + // Containers and emulators participate in the Aspire model but do not produce ARM + // deployments. Keep them out of controller operations so command state and drift checks only + // target resources backed by Azure deployment state. + return [.. AzureResourcePreparer.GetAzureResourcesFromAppModel(model).Where(static resource => + resource.AzureResource is AzureBicepResource bicepResource && + !bicepResource.IsContainer() && + !bicepResource.IsEmulator())]; + } + + private static List<(IResource Resource, IAzureResource AzureResource)> GetTargetAzureResources(DistributedApplicationModel model, string resourceName) + { + var azureResources = GetProvisionableAzureResources(model); + var targetResource = azureResources.SingleOrDefault(resource => + string.Equals(resource.Resource.Name, resourceName, StringComparisons.ResourceName) || + string.Equals(resource.AzureResource.Name, resourceName, StringComparisons.ResourceName)); + + if (targetResource == default) + { + throw new InvalidOperationException($"Azure resource '{resourceName}' was not found or cannot be reprovisioned."); + } + + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + var visitedResources = new HashSet(StringComparers.ResourceName); + var queue = new Queue<(IResource Resource, IAzureResource AzureResource)>(); + var targetResources = new List<(IResource Resource, IAzureResource AzureResource)>(); + + Enqueue(targetResource); + + // Per-resource operations need to include any provisionable Azure resource owned by the + // selected resource, including children attached through the Aspire resource graph or + // RoleAssignment annotations, so dependent resources stay in sync with the target. + while (queue.Count > 0) + { + var current = queue.Dequeue(); + targetResources.Add(current); + + foreach (var child in parentChildLookup[current.Resource]) + { + if (TryGetAzureResource(azureResources, child, out var childResource)) + { + Enqueue(childResource); + } + } + + if (!ReferenceEquals(current.Resource, current.AzureResource)) + { + foreach (var child in parentChildLookup[current.AzureResource]) + { + if (TryGetAzureResource(azureResources, child, out var childResource)) + { + Enqueue(childResource); + } + } + } + + if (current.AzureResource.TryGetAnnotationsOfType(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 QueueAndWaitForOperationAsync( + DistributedApplicationModel model, + AzureIntent intent, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + // The first Azure operation lazily starts the background pieces. This avoids creating tasks + // in apps that reference Azure hosting packages but never provision in run mode. + EnsureDriftMonitorStarted(model); + EnsureOperationLoopStarted(); + + var queuedOperation = new QueuedOperation( + model, + intent, + new(TaskCreationOptions.RunContinuationsAsynchronously), + cancellationToken); + + // All dashboard, CLI, and background Azure operations enter through this queue. + // Running them inline would reintroduce re-entrancy between command handlers and + // provisioning callbacks; the single reader below is the synchronization boundary. + await _operationChannel.Writer.WriteAsync(queuedOperation, cancellationToken).ConfigureAwait(false); + return await queuedOperation.Completion.Task.ConfigureAwait(false); + } + + 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 () => + { + // Delay before each check so the gap between drift checks is constant regardless of how long + // the previous check ran. PeriodicTimer would fire back-to-back if a check exceeded the interval. + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(DriftCheckInterval, timeProvider, stoppingToken).ConfigureAwait(false); + await CheckForDriftAsync(model, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Azure drift check failed."); + } + } + }, stoppingToken); + } + + private void EnsureOperationLoopStarted() + { + if (Interlocked.CompareExchange(ref _operationLoopStarted, 1, 0) == 0) + { + var stoppingToken = serviceProvider.GetService()?.ApplicationStopping ?? CancellationToken.None; + _ = Task.Run(async () => + { + try + { + await ProcessOperationLoopAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Azure operation processing failed."); + CancelPendingOperations(stoppingToken); + } + }, stoppingToken); + } + } + + private async Task ProcessOperationLoopAsync(CancellationToken stoppingToken) + { + try + { + await foreach (var operation in _operationChannel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false)) + { + if (operation.CancellationToken.IsCancellationRequested) + { + if (operation.Intent is DetectDriftIntent) + { + CompleteDriftCheck(); + } + + operation.Completion.TrySetCanceled(operation.CancellationToken); + continue; + } + + await ProcessQueuedOperationAsync(operation).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + CancelPendingOperations(stoppingToken); + } + } + + private void CancelPendingOperations(CancellationToken cancellationToken) + { + while (_operationChannel.Reader.TryRead(out var operation)) + { + if (operation.Intent is DetectDriftIntent) + { + CompleteDriftCheck(); + } + + operation.Completion.TrySetCanceled(cancellationToken); + } + } + + private async Task ProcessQueuedOperationAsync(QueuedOperation queuedOperation) + { + var updatesCommandState = queuedOperation.Intent is not DetectDriftIntent; + if (updatesCommandState) + { + // Publish command-state changes before running the operation so dashboard buttons + // disable immediately instead of remaining clickable until the first resource update. + StartOperation(queuedOperation.Intent); + } + + try + { + if (updatesCommandState) + { + await RefreshCommandStatesAsync(queuedOperation.Model, queuedOperation.CancellationToken).ConfigureAwait(false); + } + + 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); + // Use CancellationToken.None for the final refresh because command state must be + // re-enabled even if the operation request token was canceled after the work stopped. + await RefreshCommandStatesAsync(queuedOperation.Model, CancellationToken.None).ConfigureAwait(false); + } + else + { + // Drift detection is a background probe. It must serialize with commands, but it + // should not make dashboard commands flicker disabled while it checks ARM state. + CompleteDriftCheck(); + } + } + } + + 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 changeAzureContext => await ExecuteChangeAzureContextAsync(model, changeAzureContext, 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), + CancelResourceDeploymentIntent cancelResourceDeployment => await ExecuteCancelResourceDeploymentAsync(model, cancelResourceDeployment, cancellationToken).ConfigureAwait(false), + DeleteAzureResourceIntent deleteAzureResource => await ExecuteDeleteAzureResourceAsync(model, deleteAzureResource, cancellationToken).ConfigureAwait(false), + DetectDriftIntent => await ExecuteDetectDriftAsync(model, cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Unexpected Azure intent.") + }; + } + + private async Task ExecuteResetStateAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + // Resetting the environment removes the top-level provisioning context first, then clears + // each deployment section. This makes future prompts fall back to configuration/defaults and + // prevents resources from showing old Azure identity properties. + await DeleteSectionAsync("Azure", cancellationToken).ConfigureAwait(false); + + var azureResources = GetProvisionableAzureResources(model); + await ResetResourcesAsync(model, azureResources, preserveOverrides: false, cancellationToken).ConfigureAwait(false); + + await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Azure provisioning state reset for {Count} Azure resources.", azureResources.Count); + return null; + } + + private async Task ExecuteForgetResourceStateAsync(DistributedApplicationModel model, ForgetResourceStateIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + // Forgetting state is local-only. It deliberately does not call ARM delete; users choose the + // Delete command when they want Aspire to remove live Azure resources. + await ResetResourcesAsync(model, targetResources, preserveOverrides: false, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Azure provisioning state reset for resource {ResourceName}.", intent.ResourceName); + return null; + } + + private async Task ExecuteChangeAzureContextAsync(DistributedApplicationModel model, ChangeAzureContextIntent intent, CancellationToken cancellationToken) + { + if (intent.Options is null) + { + // This is the legacy/non-dashboard path. The options manager owns prompting and + // persistence when the command is invoked without the dashboard argument collection. + var updated = await provisioningOptionsManager.EnsureProvisioningOptionsAsync(forcePrompt: true, cancellationToken).ConfigureAwait(false); + if (!updated) + { + return false; + } + + await provisioningOptionsManager.PersistProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await provisioningOptionsManager.ApplyProvisioningOptionsAsync(intent.Options, cancellationToken).ConfigureAwait(false); + } + + // Changing subscription/resource group/location invalidates cached deployment state. Preserve + // explicit user location overrides, but drop inferred overrides that only mirrored the previous + // environment location; otherwise resources can accidentally stay pinned to the old context. + await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken, preserveInferredLocationOverrides: false).ConfigureAwait(false); + await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false); + return await EnsureProvisionedOrThrowAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false); + } + + private async Task 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 EnsureProvisionedOrThrowAsync(model, GetProvisionableAzureResources(model), cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteDeleteAzureResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + // Delete-all operates at the resource-group boundary because run-mode provisioning creates a + // single environment resource group. Per-resource deletion uses cached deployment outputs + // instead, since individual Azure resources may not map one-to-one to a resource group. + await PublishAzureEnvironmentStateAsync( + model, + new ResourceStateSnapshot("Deleting", KnownResourceStateStyles.Info), + cancellationToken).ConfigureAwait(false); + + string? resourceGroupName; + try + { + resourceGroupName = await DeleteCurrentResourceGroupIfExistsAsync(cancellationToken).ConfigureAwait(false); + + await ResetResourcesAsync(model, GetProvisionableAzureResources(model), preserveOverrides: true, cancellationToken).ConfigureAwait(false); + await PublishAzureEnvironmentStateAsync(model, KnownResourceStates.NotStarted, cancellationToken).ConfigureAwait(false); + } + catch (RequestFailedException) + { + await PublishAzureEnvironmentStateAsync( + model, + new ResourceStateSnapshot("Failed to Delete", KnownResourceStateStyles.Error), + cancellationToken).ConfigureAwait(false); + throw; + } + + if (string.IsNullOrEmpty(resourceGroupName)) + { + _logger.LogInformation("Azure deployment state reset without deleting a resource group because no Azure resource group was configured."); + } + else + { + _logger.LogInformation("Azure resource group {ResourceGroup} was deleted or was already absent.", resourceGroupName); + } + + return null; + } + + private async Task DeleteCurrentResourceGroupIfExistsAsync(CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); + var subscriptionId = section.Data["SubscriptionId"]?.GetValue(); + var resourceGroupName = section.Data["ResourceGroup"]?.GetValue(); + if (string.IsNullOrWhiteSpace(subscriptionId) || + string.IsNullOrWhiteSpace(resourceGroupName)) + { + return null; + } + + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, subscriptionId); + // Resolve the subscription through ARM before accessing resource groups so tenant/subscription + // mismatches fail with the same Azure SDK behavior used by provisioning. + var (subscription, _) = await armClient.GetSubscriptionAndTenantAsync(cancellationToken).ConfigureAwait(false); + + try + { + var response = await subscription.GetResourceGroups().GetAsync(resourceGroupName, cancellationToken).ConfigureAwait(false); + await response.Value.DeleteAsync(WaitUntil.Completed, cancellationToken).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogInformation("Azure resource group {ResourceGroup} was already absent.", resourceGroupName); + } + + return resourceGroupName; + } + + private async Task ExecuteChangeResourceLocationAsync(DistributedApplicationModel model, ChangeResourceLocationIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + if (targetResources[0].AzureResource is AzureBicepResource targetBicepResource) + { + // ARM rejects redeploying many resource types to a different location while the old + // resource still exists. Delete the cached live resource first, then save the override + // that the next provisioning pass will apply. + await DeleteCachedResourceForLocationChangeAsync(targetBicepResource, intent.Location, cancellationToken).ConfigureAwait(false); + await SetResourceLocationOverrideAsync(targetBicepResource.Name, intent.Location, cancellationToken).ConfigureAwait(false); + } + await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false); + return await EnsureProvisionedOrThrowAsync(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 EnsureProvisionedOrThrowAsync(model, targetResources, cancellationToken).ConfigureAwait(false); + } + + private async Task ExecuteCancelResourceDeploymentAsync(DistributedApplicationModel model, CancelResourceDeploymentIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + var canceledDeploymentCount = await CancelCachedDeploymentsAsync(targetResources, requireDeployment: true, cancellationToken).ConfigureAwait(false); + + foreach (var resource in targetResources) + { + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Canceled", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + } + + _logger.LogInformation("Canceled {Count} Azure deployment(s) for resource {ResourceName}.", canceledDeploymentCount, intent.ResourceName); + return null; + } + + private async Task ExecuteDeleteAzureResourceAsync(DistributedApplicationModel model, DeleteAzureResourceIntent intent, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, intent.ResourceName); + var parentChildLookup = model.Resources.OfType().ToLookup(r => r.Parent); + + foreach (var resource in targetResources) + { + // Show the whole affected resource tree as deleting before the first ARM call so the + // dashboard reflects that child resources/role assignments are part of the operation. + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Deleting", KnownResourceStateStyles.Info) + }).ConfigureAwait(false); + } + + IReadOnlyList resourceIds; + try + { + // A resource can have an in-progress deployment and already-created target resources. + // Cancel first to stop ARM from continuing to create/update resources while deletion is + // collecting and removing the known targets. + await CancelCachedDeploymentsAsync(targetResources, requireDeployment: false, cancellationToken).ConfigureAwait(false); + resourceIds = await GetAzureResourceIdsForDeletionAsync(targetResources, cancellationToken).ConfigureAwait(false); + if (resourceIds.Count == 0) + { + throw new InvalidOperationException($"No cached Azure resource IDs were found for resource '{intent.ResourceName}'. Use '{ForgetStateCommandName}' to clear local state only."); + } + + await DeleteAzureResourceIdsAsync(resourceIds, intent.ResourceName, cancellationToken).ConfigureAwait(false); + await ResetResourcesAsync(model, targetResources, preserveOverrides: true, cancellationToken).ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + foreach (var resource in targetResources) + { + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Failed to Delete", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + + throw; + } + + _logger.LogInformation("Deleted {Count} Azure resource(s) for resource {ResourceName}.", resourceIds.Count, intent.ResourceName); + return new DeleteAzureResourceResult(resourceIds); + } + + private async Task ExecuteDetectDriftAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + // Drift only matters after the environment is Running. During provisioning, reset, or delete + // operations the resource states already communicate that Azure may not match cached state. + if (model.Resources.OfType().SingleOrDefault() is not { } environmentResource || + !notificationService.TryGetCurrentState(environmentResource.Name, out var environmentEvent) || + environmentEvent.Snapshot.State?.Text != KnownResourceStates.Running) + { + return null; + } + + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(context.SubscriptionId, out _)) + { + // Without a valid subscription we cannot safely ask ARM whether cached resource IDs + // exist. Leave state unchanged rather than marking everything as drifted. + return null; + } + + var armClientProvider = serviceProvider.GetRequiredService(); + 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) + { + // Resources without cached IDs are either not provisioned yet or already reset. They + // do not provide enough information for a live ARM existence check. + continue; + } + + if (await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false)) + { + continue; + } + + driftedResources ??= []; + driftedResources.Add(resource.Resource.Name); + + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new(MissingInAzureState, KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + + if (driftedResources is null) + { + return null; + } + + await PublishAzureEnvironmentStateAsync( + model, + new ResourceStateSnapshot(DriftedState, KnownResourceStateStyles.Error), + cancellationToken).ConfigureAwait(false); + + _logger.LogWarning("Azure drift detected for resources: {ResourceNames}.", string.Join(", ", driftedResources)); + + return null; + } + + private void StartOperation(AzureIntent intent) + { + lock (_operationStateLock) + { + // Store the intent rather than just a boolean so command-state calculation can keep + // unaffected resources enabled while a per-resource operation is running. + _state = CreateControllerState(intent); + } + } + + 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 static AzureControllerState CreateControllerState(AzureIntent? currentIntent) + => new(new AzureControllerStatus(currentIntent)); + + private static async Task ExecuteCommandAsync(Func action, string successMessage, Func> createResultData) + { + try + { + await action().ConfigureAwait(false); + return CommandResults.Success(successMessage, await createResultData().ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + return CommandResults.Canceled(); + } + catch (Exception ex) + { + return CommandResults.Failure(ex.Message); + } + } + + private static async Task ExecuteCommandAsync(Func> action, string successMessage, Func> createResultData) + { + try + { + var result = await action().ConfigureAwait(false); + return CommandResults.Success(successMessage, await createResultData(result).ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + return CommandResults.Canceled(); + } + catch (Exception ex) + { + return CommandResults.Failure(ex.Message); + } + } + + private static async Task ExecuteCommandAsync(Func> action, string successMessage, Func> createResultData) + { + try + { + return await action().ConfigureAwait(false) + ? CommandResults.Success(successMessage, await createResultData().ConfigureAwait(false)) + : CommandResults.Canceled(); + } + catch (OperationCanceledException) + { + return CommandResults.Canceled(); + } + catch (Exception ex) + { + return CommandResults.Failure(ex.Message); + } + } + + private async Task CreateEnvironmentCommandResultDataAsync(string commandName, DistributedApplicationModel model, CancellationToken cancellationToken) + { + var json = await CreateCommandResultJsonAsync(commandName, resourceName: null, cancellationToken).ConfigureAwait(false); + json["resourceCount"] = GetProvisionableAzureResources(model).Count; + return CreateJsonResultData(json); + } + + private async Task CreateResourceCommandResultDataAsync(string commandName, DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken) + => CreateJsonResultData(await CreateResourceCommandResultJsonAsync(commandName, model, resourceName, cancellationToken).ConfigureAwait(false)); + + private async Task CreateDeleteAzureResourceCommandResultDataAsync(DistributedApplicationModel model, string resourceName, DeleteAzureResourceResult result, CancellationToken cancellationToken) + { + var json = await CreateResourceCommandResultJsonAsync(DeleteAzureResourceCommandName, model, resourceName, cancellationToken).ConfigureAwait(false); + var deletedResourceIds = new JsonArray(); + foreach (var resourceId in result.ResourceIds) + { + deletedResourceIds.Add(JsonValue.Create(resourceId)); + } + + json["deletedResourceCount"] = result.ResourceIds.Count; + json["deletedResourceIds"] = deletedResourceIds; + return CreateJsonResultData(json); + } + + private async Task CreateResourceCommandResultJsonAsync(string commandName, DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken) + { + var json = await CreateCommandResultJsonAsync(commandName, resourceName, cancellationToken).ConfigureAwait(false); + var targetResources = GetTargetAzureResources(model, resourceName); + json["resourceCount"] = targetResources.Count; + json["location"] = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResources[0]), cancellationToken).ConfigureAwait(false); + return json; + } + + private async Task CreateAzureResourceInfoCommandResultDataAsync(DistributedApplicationModel model, string resourceName, CancellationToken cancellationToken) + { + var targetResources = GetTargetAzureResources(model, resourceName); + + // Targeting a parent Azure resource can include children and role assignments that must + // be reprovisioned together. The info command, however, reports the resource the user + // named so agents can map the command output back to the visible dashboard resource. + var targetResource = targetResources[0]; + var json = await CreateCommandResultJsonAsync(GetAzureResourceCommandName, resourceName, cancellationToken).ConfigureAwait(false); + json["resourceCount"] = targetResources.Count; + json["location"] = await GetEffectiveResourceLocationAsync(GetDeploymentStateResourceName(targetResource), cancellationToken).ConfigureAwait(false); + + if (targetResource.AzureResource is AzureBicepResource bicepResource) + { + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + // Include both cached deployment state and a best-effort live probe. Cached state is + // available offline; the live block lets agents distinguish "state exists but resource + // was deleted" from "state exists and ARM can still find it". + var deployment = await CreateCachedDeploymentStateInfoAsync(bicepResource, context, cancellationToken).ConfigureAwait(false); + json["deployment"] = deployment; + json["live"] = await CreateLiveResourceInfoAsync( + deployment.TryGetPropertyValue("resourceId", out var resourceIdNode) ? resourceIdNode?.GetValue() : null, + context, + cancellationToken).ConfigureAwait(false); + } + + return CreateJsonResultData(json, displayImmediately: true); + } + + private async Task CreateCachedDeploymentStateInfoAsync(AzureBicepResource resource, AzureContextState context, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + var deploymentId = section.Data["Id"]?.GetValue(); + // Deployment state stores JSON as strings because it is shared with the lower-level + // provisioner. Parse lazily for the diagnostic command so malformed cached state can be + // reported next to the raw payload instead of breaking command execution. + var outputs = ParseDeploymentStateJson(resource.Name, "Outputs", section.Data["Outputs"]?.GetValue()); + var resourceId = TryGetOutputValue(outputs, "id"); + var tenantId = Guid.TryParse(context.TenantId, out var parsedTenantId) ? parsedTenantId : (Guid?)null; + + var json = new JsonObject + { + ["hasState"] = section.Data.Count > 0, + ["deploymentId"] = deploymentId, + ["resourceId"] = resourceId, + ["resourcePortalUrl"] = resourceId is not null ? AzurePortalUrls.GetResourceUrl(resourceId, tenantId) : null, + ["locationOverride"] = section.Data[LocationOverrideKey]?.GetValue(), + ["checksum"] = section.Data["CheckSum"]?.GetValue(), + ["parameters"] = ParseDeploymentStateJson(resource.Name, "Parameters", section.Data["Parameters"]?.GetValue()), + ["outputs"] = outputs, + ["scope"] = ParseDeploymentStateJson(resource.Name, "Scope", section.Data["Scope"]?.GetValue()) + }; + + if (section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue() is { Length: > 0 } provisioningState) + { + json["provisioningState"] = provisioningState; + } + + if (deploymentId is not null && + ResourceIdentifier.TryParse(deploymentId, out var deploymentResourceId) && + deploymentResourceId is not null) + { + // The deployment ID is itself an ARM resource ID. If it parses, provide a portal link to + // the deployment operation in addition to the provisioned resource link. + json["deploymentPortalUrl"] = AzurePortalUrls.GetDeploymentUrl(deploymentResourceId); + } + + return json; + } + + private async Task CreateLiveResourceInfoAsync(string? resourceId, AzureContextState context, CancellationToken cancellationToken) + { + // Start with a "not checked" shape so every early-return path is explicit and callers do not + // need to infer why live ARM state is missing from absent properties. + var json = new JsonObject + { + ["checked"] = false, + ["exists"] = null + }; + + if (string.IsNullOrWhiteSpace(resourceId)) + { + json["reason"] = "missing-resource-id"; + return json; + } + + if (!Guid.TryParse(context.SubscriptionId, out _)) + { + json["reason"] = "invalid-subscription-id"; + return json; + } + + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId); + json["checked"] = true; + json["exists"] = await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false); + } + catch (CredentialUnavailableException ex) + { + // get-azure-resource is a diagnostic command. Return a machine-readable reason instead + // of failing the command so local runs without Azure auth still expose cached state. + _logger.LogDebug(ex, "Unable to query live Azure resource state for {ResourceId} because no Azure credential is available.", resourceId); + json["reason"] = "credential-unavailable"; + json["message"] = ex.Message; + } + catch (RequestFailedException ex) + { + // Surface ARM failures as structured JSON so agents can distinguish "missing", + // authorization failures, and transient request errors without scraping logs. + _logger.LogDebug(ex, "Unable to query live Azure resource state for {ResourceId}.", resourceId); + json["reason"] = "request-failed"; + json["status"] = ex.Status; + json["errorCode"] = ex.ErrorCode; + json["message"] = ex.Message; + } + + return json; + } + + private JsonNode? ParseDeploymentStateJson(string resourceName, string propertyName, string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + // Keep parse failures in the command payload instead of throwing so a diagnostic + // command can still show the rest of the cached state. + return AzureProvisioningJsonHelpers.ParseDeploymentStateJson(json); + } + catch (JsonException ex) + { + _logger.LogDebug(ex, "Unable to parse cached {PropertyName} for Azure resource {ResourceName}.", propertyName, resourceName); + return new JsonObject + { + ["parseError"] = ex.Message, + ["raw"] = json + }; + } + } + + private static string? TryGetOutputValue(JsonNode? outputs, string outputName) + { + // Bicep deployment outputs are persisted in the ARM output shape: + // { "id": { "type": "String", "value": "/subscriptions/..." } } + // Only the nested value is useful to commands; ignore partial/malformed output entries. + if (outputs is not JsonObject outputsObject || + !outputsObject.TryGetPropertyValue(outputName, out var outputNode) || + outputNode is not JsonObject outputObject || + !outputObject.TryGetPropertyValue("value", out var valueNode)) + { + return null; + } + + return valueNode?.ToString(); + } + + private async Task CreateCommandResultJsonAsync(string commandName, string? resourceName, CancellationToken cancellationToken) + { + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + // Keep command output machine-readable and stable for agents/CLI automation. Additional + // command-specific details are appended by the caller, but the Azure context fields are + // present on every successful Azure command result. + var json = new JsonObject + { + ["schemaVersion"] = 1, + ["command"] = commandName, + ["success"] = true, + ["subscriptionId"] = context.SubscriptionId, + ["tenantId"] = context.TenantId, + ["resourceGroup"] = context.ResourceGroup, + ["azureLocation"] = context.Location + }; + + if (!string.IsNullOrEmpty(resourceName)) + { + json["resourceName"] = resourceName; + } + + return json; + } + + private static CommandResultData CreateJsonResultData(JsonObject json, bool displayImmediately = false) => + new() + { + // Serialize through the shared helper so formatting/scrubbing stays consistent with the + // rest of Azure provisioning diagnostics. + Value = AzureProvisioningJsonHelpers.ToCommandResultJsonString(json), + Format = CommandResultFormat.Json, + DisplayImmediately = displayImmediately + }; + + private 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) + { + // Normalize old override values opportunistically. Older state or manual edits may store + // display names such as "West US 2"; Bicep parameters should use canonical names such as + // "westus2" so equality checks and ARM deployments are stable. + var normalizedLocation = NormalizeLocation(locationOverride, await GetLocationOptionsAsync(cancellationToken).ConfigureAwait(false)); + if (!string.Equals(normalizedLocation, locationOverride, StringComparison.Ordinal)) + { + section.Data[LocationOverrideKey] = normalizedLocation; + await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + + bicepResource.Parameters[AzureBicepResource.KnownParameters.Location] = normalizedLocation; + } + } + + private async Task 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) + { + // Per-resource overrides win over the environment location. The dashboard command uses + // this to show the effective value users will get on the next provisioning pass. + return locationOverride; + } + + return (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).Location; + } + + private async Task SetResourceLocationOverrideAsync(string resourceName, string location, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resourceName}", cancellationToken).ConfigureAwait(false); + section.Data[LocationOverrideKey] = location; + await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + + // Deployment state is keyed by the AzureBicepResource name because the provisioner owns that + // state. Some visible resources are projected to a separate AzureBicepResource, so do not use + // the visible Aspire resource name when a Bicep resource is available. + private static string GetDeploymentStateResourceName((IResource Resource, IAzureResource AzureResource) resource) + => resource.AzureResource is AzureBicepResource bicepResource ? bicepResource.Name : resource.Resource.Name; + + private async Task CancelCachedDeploymentsAsync( + IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> targetResources, + bool requireDeployment, + CancellationToken cancellationToken) + { + var canceledDeploymentIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var resource in targetResources) + { + if (resource.AzureResource is not AzureBicepResource bicepResource) + { + continue; + } + + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{bicepResource.Name}", cancellationToken).ConfigureAwait(false); + if (TryGetCachedDeploymentId(section) is not { } deploymentId || + !IsActiveCachedDeployment(section)) + { + // Only active cached deployments can be canceled. Completed or missing deployments + // should not turn a best-effort cleanup into an error unless the caller explicitly + // required an active deployment below. + continue; + } + + if (canceledDeploymentIds.Add(deploymentId)) + { + // Multiple Aspire resources can share one ARM deployment. Track IDs so a resource + // tree cancellation sends at most one cancel request per deployment. + await CancelCachedDeploymentAsync(deploymentId, loggerService.GetLogger(resource.AzureResource), cancellationToken).ConfigureAwait(false); + } + + // Mark local deployment state canceled even if ARM had already finished between reading + // state and sending the cancel request. That prevents future command state from treating + // this cached deployment as still cancelable. + section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateCanceled; + await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + + if (requireDeployment && canceledDeploymentIds.Count == 0) + { + var resourceName = targetResources.Count == 1 ? targetResources.Single().Resource.Name : string.Join(", ", targetResources.Select(static resource => resource.Resource.Name)); + throw new InvalidOperationException($"No active cached Azure deployment was found for resource '{resourceName}'."); + } + + return canceledDeploymentIds.Count; + } + + private async Task CancelCachedDeploymentAsync(string deploymentId, ILogger resourceLogger, CancellationToken cancellationToken) + { + var armClient = await GetArmClientForResourceIdAsync(deploymentId, cancellationToken).ConfigureAwait(false); + + try + { + await armClient.CancelDeploymentAsync(deploymentId, cancellationToken).ConfigureAwait(false); + resourceLogger.LogInformation("Cancellation requested for Azure deployment {DeploymentId}.", deploymentId); + } + catch (RequestFailedException ex) when (ex.Status == 404 || ex.Status == 409) + { + // ARM returns 404 when the deployment record is already gone and 409 when the deployment + // is no longer in a cancelable state. Both mean the requested end state has effectively + // been reached from Aspire's perspective. + _logger.LogInformation(ex, "Azure deployment {DeploymentId} was already absent or no longer active during cancellation.", deploymentId); + resourceLogger.LogInformation("Azure deployment {DeploymentId} was already absent or no longer active during cancellation.", deploymentId); + } + } + + private async Task> GetAzureResourceIdsForDeletionAsync( + IReadOnlyCollection<(IResource Resource, IAzureResource AzureResource)> targetResources, + CancellationToken cancellationToken) + { + var resourceIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var resource in targetResources) + { + if (resource.AzureResource is not AzureBicepResource bicepResource) + { + continue; + } + + if (await TryGetResourceIdFromDeploymentStateAsync(bicepResource, cancellationToken).ConfigureAwait(false) is { } resourceId && + !IsArmDeploymentResourceId(resourceId)) + { + // The primary output ID is usually the user-visible resource. Exclude deployment + // resources because deleting the ARM deployment record does not delete the resources + // the deployment created. + resourceIds.Add(resourceId); + } + + // Some Bicep files create more than one Azure resource. Ask ARM for the deployment + // operation targets so delete-resource removes all created resources, not just the main + // output ID. + await AddDeploymentOperationTargetResourceIdsAsync(bicepResource, resourceIds, cancellationToken).ConfigureAwait(false); + } + + // Delete children before parents by ordering longer resource IDs first. Azure child resource + // IDs include their parent ID as a prefix, so length is a cheap dependency-safe heuristic. + return [.. resourceIds.OrderByDescending(static resourceId => resourceId.Length)]; + } + + private async Task AddDeploymentOperationTargetResourceIdsAsync(AzureBicepResource resource, HashSet resourceIds, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + if (TryGetCachedDeploymentId(section) is not { } deploymentId) + { + return; + } + + var armClient = await GetArmClientForResourceIdAsync(deploymentId, cancellationToken).ConfigureAwait(false); + try + { + await foreach (var resourceId in armClient.GetDeploymentTargetResourceIdsAsync(deploymentId, cancellationToken).ConfigureAwait(false)) + { + if (!IsArmDeploymentResourceId(resourceId)) + { + resourceIds.Add(resourceId); + } + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogInformation(ex, "Azure deployment {DeploymentId} was absent while collecting target resources for {ResourceName}.", deploymentId, resource.Name); + } + } + + private async Task DeleteAzureResourceIdsAsync(IReadOnlyList resourceIds, string resourceName, CancellationToken cancellationToken) + { + foreach (var resourceId in resourceIds) + { + // Resolve the ARM client per resource ID because a resource tree can contain resources + // from deployment state that point at a different subscription than the current context. + var armClient = await GetArmClientForResourceIdAsync(resourceId, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Deleting Azure resource {ResourceId} for {ResourceName}.", resourceId, resourceName); + + try + { + await armClient.DeleteResourceAsync(resourceId, cancellationToken).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogInformation(ex, "Azure resource {ResourceId} was already absent while deleting resources for {ResourceName}.", resourceId, resourceName); + } + } + } + + private async Task GetArmClientForResourceIdAsync(string resourceId, CancellationToken cancellationToken) + { + string? subscriptionId = null; + if (ResourceIdentifier.TryParse(resourceId, out var parsedResourceId) && + parsedResourceId is not null) + { + // Prefer the subscription embedded in the ARM resource ID. This makes cleanup resilient + // after the user changes Azure context but still has cached state for old resources. + subscriptionId = parsedResourceId.SubscriptionId; + } + + if (string.IsNullOrWhiteSpace(subscriptionId)) + { + subscriptionId = (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).SubscriptionId; + } + + if (!Guid.TryParse(subscriptionId, out _)) + { + throw new MissingConfigurationException("Azure resources cannot be managed because the Azure subscription ID is missing or invalid."); + } + + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + return armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, subscriptionId); + } + + private static string? TryGetCachedDeploymentId(DeploymentStateSection section) + => section.Data["Id"]?.GetValue() is { Length: > 0 } deploymentId ? deploymentId : null; + + private static bool IsActiveCachedDeployment(DeploymentStateSection section) + => string.Equals( + section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue(), + BicepProvisioner.DeploymentStateProvisioningStateRunning, + StringComparison.Ordinal); + + private static bool IsArmDeploymentResourceId(string resourceId) + { + if (!ResourceIdentifier.TryParse(resourceId, out var parsedResourceId) || + parsedResourceId is null) + { + return false; + } + + return string.Equals(parsedResourceId.ResourceType.ToString(), "Microsoft.Resources/deployments", StringComparison.OrdinalIgnoreCase); + } + + private string? TryGetCurrentResourceLocationOverride(AzureBicepResource resource, string? environmentLocation) + { + var currentLocationValue = TryGetCurrentResourceLocation(resource); + if (!string.IsNullOrWhiteSpace(currentLocationValue) && + (string.IsNullOrWhiteSpace(environmentLocation) || + !string.Equals(currentLocationValue, environmentLocation, StringComparison.OrdinalIgnoreCase))) + { + // The dashboard snapshot is the most recent observed effective location. Prefer it when + // deciding whether a reset should preserve a per-resource override. + return currentLocationValue; + } + + if (resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var parameterLocation) && + parameterLocation?.ToString() is { Length: > 0 } parameterLocationValue && + (string.IsNullOrWhiteSpace(environmentLocation) || + !string.Equals(parameterLocationValue, environmentLocation, StringComparison.OrdinalIgnoreCase))) + { + // If the Bicep parameter is already different from the environment location, treat it as + // an explicit per-resource setting that should survive reset/reprovision operations. + return parameterLocationValue; + } + + return null; + } + + private string? TryGetCurrentResourceLocation(AzureBicepResource resource) + { + if (!notificationService.TryGetCurrentState(resource.Name, out var resourceEvent)) + { + return null; + } + + return resourceEvent.Snapshot.Properties + .FirstOrDefault(static p => string.Equals(p.Name, "azure.location", StringComparison.Ordinal)) + ?.Value?.ToString(); + } + + private string? TryGetPreservedLocationOverride(AzureBicepResource resource, DeploymentStateSection section, string? environmentLocation) + { + if (TryGetExplicitLocationOverride(section) is { } locationOverride) + { + // Explicit overrides come from the Change location command and should survive even when + // they match the current environment location. + return locationOverride; + } + + if (section.Data["Parameters"]?.GetValue() is not { Length: > 0 } parametersJson) + { + return null; + } + + try + { + var persistedLocation = AzureProvisioningJsonHelpers.ParseDeploymentStateJson(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue(); + if (string.IsNullOrWhiteSpace(persistedLocation)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(environmentLocation) || + !string.Equals(persistedLocation, environmentLocation, StringComparison.OrdinalIgnoreCase)) + { + // A resource can intentionally live in a different Azure region than the environment. + // Preserve that persisted per-resource value across reprovisioning instead of replacing + // it with the current environment location. + return persistedLocation; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to parse persisted parameters while preserving Azure resource location overrides."); + } + + return TryGetCurrentResourceLocationOverride(resource, environmentLocation); + } + + private static string? TryGetExplicitLocationOverride(DeploymentStateSection section) + => section.Data[LocationOverrideKey]?.GetValue() is { Length: > 0 } locationOverride ? locationOverride : null; + + 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)) + { + // Prefer the option key because Azure SDK/Bicep APIs expect canonical location names + // even though users often choose or type display names. + return option.Key; + } + } + + var canonicalLocation = CanonicalizeLocation(location); + if (!string.Equals(canonicalLocation, location, StringComparison.Ordinal)) + { + return canonicalLocation; + } + + return location; + } + + private static string CanonicalizeLocation(string location) + { + // Last-resort normalization for manually typed display names when ARM enumeration is not + // available. Azure canonical names are lowercase alphanumeric values such as "westus2". + Span 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) + { + return await GetLocationOptionsAsync(subscriptionId: null, cancellationToken).ConfigureAwait(false); + } + + private async Task>> GetTenantOptionsAsync(CancellationToken cancellationToken) + { + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential); + + return [.. (await armClient.GetAvailableTenantsAsync(cancellationToken).ConfigureAwait(false)) + .Select(static tenant => + { + var tenantId = tenant.TenantId?.ToString() ?? ""; + var displayName = !string.IsNullOrEmpty(tenant.DisplayName) + ? tenant.DisplayName + : !string.IsNullOrEmpty(tenant.DefaultDomain) + ? tenant.DefaultDomain + : "Unknown"; + + var description = displayName; + if (!string.IsNullOrEmpty(tenant.DefaultDomain) && + !string.Equals(tenant.DisplayName, tenant.DefaultDomain, StringComparison.Ordinal)) + { + description += $" ({tenant.DefaultDomain})"; + } + + return KeyValuePair.Create(tenantId, $"{description} — {tenantId}"); + }) + .OrderBy(static option => option.Value)]; + } + catch (Exception ex) + { + // Enumeration improves the dialog but is not required because Azure IDs are accepted as + // custom choices. Log and keep the command usable in restricted/offline environments. + _logger.LogWarning(ex, "Failed to enumerate Azure tenants for context selection."); + return []; + } + } + + private async Task>> GetSubscriptionOptionsAsync(string? tenantId, CancellationToken cancellationToken) + { + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential); + + return [.. (await armClient.GetAvailableSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false)) + .Select(static subscription => KeyValuePair.Create( + subscription.Id.SubscriptionId ?? "", + $"{subscription.DisplayName ?? subscription.Id.SubscriptionId} ({subscription.Id.SubscriptionId})")) + .OrderBy(static option => option.Value)]; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate Azure subscriptions for context selection."); + return []; + } + } + + private async Task> GetResourceGroupOptionsAsync(string? subscriptionId, CancellationToken cancellationToken) + { + if (!Guid.TryParse(subscriptionId, out _)) + { + // Resource group enumeration requires a valid subscription ID. Return no options rather + // than throwing so the dialog can still accept a manually entered resource group. + return []; + } + + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential); + + return [.. await armClient.GetAvailableResourceGroupsWithLocationAsync(subscriptionId, cancellationToken).ConfigureAwait(false)]; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate Azure resource groups for context selection."); + return []; + } + } + + private async Task>> GetLocationOptionsAsync(string? subscriptionId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(subscriptionId)) + { + subscriptionId = (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).SubscriptionId; + } + + if (!Guid.TryParse(subscriptionId, out _)) + { + // Location has a useful static fallback. Use it when the subscription is missing or + // invalid so users can still pick common Azure regions before the context is complete. + return GetStaticLocationOptions(); + } + + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + 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) + { + // Fall back to the AzureLocation catalog when ARM enumeration fails. This keeps the + // location picker useful even if the user lacks permission to list locations. + _logger.LogWarning(ex, "Failed to enumerate Azure locations for resource override."); + return GetStaticLocationOptions(); + } + } + + private static IReadOnlyList> 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); + + // Persisted dashboard/CLI choices have highest precedence. Options/configuration are + // fallback defaults for first run or after reset, matching the provisioning context provider. + return new AzureContextState( + section.Data["SubscriptionId"]?.GetValue() ?? 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)) + { + // No dashboard state means the resource has not been published yet, so a background ARM + // probe would race startup rather than detecting meaningful drift. + return false; + } + + return resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running; + } + + private async Task 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) + { + // Resources that were never successfully deployed will not have an output ID. Treat that + // as "no live resource to check" rather than a drift/delete failure. + return null; + } + + try + { + return AzureProvisioningJsonHelpers.ParseDeploymentStateJson(outputsJson)?["id"]?["value"]?.GetValue(); + } + 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) ?? + await TryGetPersistedResourceLocationAsync(resource, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(currentLocation) || + string.Equals(currentLocation, requestedLocation, StringComparison.OrdinalIgnoreCase)) + { + // If the current location is unknown or already matches the requested location, there is + // nothing safe or necessary to delete before reprovisioning. + return; + } + + if (await TryGetResourceIdFromDeploymentStateAsync(resource, cancellationToken).ConfigureAwait(false) is not { } resourceId) + { + // Without a cached resource ID we cannot target the old live resource. Let + // reprovisioning proceed and surface any ARM conflict through the normal deployment path. + return; + } + + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(context.SubscriptionId, out _)) + { + // Deleting for a location change is a best-effort preflight. If context is invalid, avoid + // making a destructive call and let the subsequent provisioning validation report the + // missing subscription configuration. + return; + } + + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId); + if (!await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false)) + { + // Cached state can point at a resource that has already been manually deleted. In that + // case the location change only needs to update local override state and reprovision. + return; + } + + _logger.LogInformation( + "Deleting Azure resource {ResourceId} before reprovisioning {ResourceName} from {CurrentLocation} to {RequestedLocation}.", + resourceId, + resource.Name, + currentLocation, + requestedLocation); + + try + { + await armClient.DeleteResourceAsync(resourceId, cancellationToken).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogInformation( + "Azure resource {ResourceId} was already absent before reprovisioning {ResourceName} from {CurrentLocation} to {RequestedLocation}.", + resourceId, + resource.Name, + currentLocation, + requestedLocation); + } + } + + private async Task TryGetPersistedResourceLocationAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + if (section.Data[LocationOverrideKey]?.GetValue() is { Length: > 0 } locationOverride) + { + // The explicit override is the most reliable persisted effective location because it was + // written by the controller rather than inferred from a deployment payload. + return locationOverride; + } + + if (section.Data["Parameters"]?.GetValue() is not { Length: > 0 } parametersJson) + { + return null; + } + + try + { + return AzureProvisioningJsonHelpers.ParseDeploymentStateJson(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to parse persisted parameters while checking whether Azure resource {ResourceName} must be deleted for a location change.", resource.Name); + return null; + } + } + + private async Task IsMissingCachedResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + if (await TryGetResourceIdFromDeploymentStateAsync(resource, cancellationToken).ConfigureAwait(false) is not { } resourceId) + { + // No cached ID means there is no prior live resource to verify. + return false; + } + + var context = await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false); + if (!Guid.TryParse(context.SubscriptionId, out _)) + { + // Missing context should fail during provisioning setup, not be interpreted as drift. + return false; + } + + try + { + var armClientProvider = serviceProvider.GetRequiredService(); + var tokenCredentialProvider = serviceProvider.GetRequiredService(); + var armClient = armClientProvider.GetArmClient(tokenCredentialProvider.TokenCredential, context.SubscriptionId); + return !await armClient.ResourceExistsAsync(resourceId, cancellationToken).ConfigureAwait(false); + } + catch (CredentialUnavailableException ex) + { + // Offline development should not clear otherwise valid cached state just because the + // drift probe cannot authenticate. + _logger.LogDebug(ex, "Unable to verify cached Azure resource state for {ResourceName} because no Azure credential is available.", resource.Name); + return false; + } + catch (RequestFailedException ex) + { + // Treat probe failures as inconclusive. A transient ARM or authorization failure should + // not force reprovisioning and potentially overwrite valid resources. + _logger.LogDebug(ex, "Unable to verify cached Azure resource state for {ResourceName} because the Azure resource probe failed.", resource.Name); + return false; + } + } + + private async Task ClearCachedDeploymentStateAsync( + AzureBicepResource resource, + bool preserveOverrides, + string? environmentLocation, + string? currentLocationOverride, + bool preserveInferredLocationOverrides, + CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + var locationOverride = preserveOverrides + ? TryGetExplicitLocationOverride(section) ?? (preserveInferredLocationOverrides + ? currentLocationOverride ?? TryGetPreservedLocationOverride(resource, section, environmentLocation) + : null) + : null; + + // Clear all provisioner state in one place so outputs, deployment IDs, checksums, and old + // parameters cannot leak into the next provisioning pass. Only the selected location override + // is intentionally copied forward. + section.Data.Clear(); + if (locationOverride is not null) + { + section.Data[LocationOverrideKey] = locationOverride; + await deploymentStateManager.SaveSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + else + { + // Removing the section entirely keeps the deployment state store small and lets callers + // distinguish "no state" from "state exists only for an override". + await deploymentStateManager.DeleteSectionAsync(section, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RefreshCommandStatesAsync(DistributedApplicationModel model, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var resource in GetResourcesForCommandStateRefresh(model)) + { + // Publishing the same state is intentional: command availability is calculated during + // snapshot publication, so a no-op state update still refreshes dashboard command state. + await notificationService.PublishUpdateAsync(resource, 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)) + { + // Include both the visible resource and its Azure provisioning surrogate. Some command + // annotations are attached to one while dashboard state may be keyed by the other. + Add(resource); + Add(azureResource); + } + + return resources; + + void Add(IResource resource) + { + if (seenNames.Add(resource.Name)) + { + resources.Add(resource); + } + } + } + + private async Task PublishUpdateToResourceTreeAsync( + (IResource Resource, IAzureResource AzureResource) resource, + ILookup parentChildLookup, + Func stateFactory) + { + async Task PublishAsync(IResource targetResource) + { + await notificationService.PublishUpdateAsync(targetResource, stateFactory).ConfigureAwait(false); + } + + // Some model resources are represented by a surrogate AzureBicepResource during + // provisioning. Publish to both so CLI wait/dashboard state stays consistent whether + // callers address the visible resource or the Azure resource used by the provisioner. + await PublishAsync(resource.AzureResource).ConfigureAwait(false); + + if (resource.Resource != resource.AzureResource) + { + await PublishAsync(resource.Resource).ConfigureAwait(false); + } + + var childResources = parentChildLookup[resource.Resource].ToList(); + + for (var i = 0; i < childResources.Count; i++) + { + var child = childResources[i]; + + foreach (var grandChild in parentChildLookup[child]) + { + if (!childResources.Contains(grandChild)) + { + // Walk descendants without recursion so deeply nested resource graphs do not + // risk stack growth while publishing a broad parent update. + childResources.Add(grandChild); + } + } + + await PublishAsync(child).ConfigureAwait(false); + } + } + + private async Task AfterProvisionAsync( + (IResource Resource, IAzureResource AzureResource) resource, + ILookup parentChildLookup) + { + try + { + await resource.AzureResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); + + // ARM deployment completion only means the resources exist. Role assignment + // propagation can lag, so do not mark the resource Running until the assigned + // principals can actually use the provisioned resource. + var rolesFailed = await WaitForRoleAssignmentsAsync(resource, parentChildLookup).ConfigureAwait(false); + if (!rolesFailed) + { + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("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) + { + // A resource can depend on role assignments that are modeled as separate Azure + // resources. Wait for those provisioning TCSs before marking the main resource + // running so dependent app code does not race RBAC propagation. + await roleAssignment.RolesResource.ProvisioningTaskCompletionSource!.Task.ConfigureAwait(false); + } + } + catch (Exception) + { + rolesFailed = true; + await PublishUpdateToResourceTreeAsync(resource, parentChildLookup, state => state with + { + State = new("Failed to Provision Roles", KnownResourceStateStyles.Error) + }).ConfigureAwait(false); + } + } + + return rolesFailed; + } + + private async Task ProvisionAzureResourcesAsync( + IReadOnlyList<(IResource Resource, IAzureResource AzureResource)> azureResources, + ILookup parentChildLookup, + CancellationToken cancellationToken) + { + // Share one provisioning context across the batch, but let each resource complete its own provisioning TCS so + // dependent resources can continue as soon as their prerequisites are ready. + // The Lazy prevents Azure context creation until at least one resource actually needs ARM + // provisioning; resources satisfied from existing configuration/user secrets can complete + // without forcing Azure authentication. + var provisioningContextLazy = new Lazy>(() => 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 resourceLogger = loggerService.GetLogger(resource.AzureResource); + + try + { + var beforeResourceStartedEvent = new BeforeResourceStartedEvent(resource.Resource, serviceProvider); + await eventing.PublishAsync(beforeResourceStartedEvent, cancellationToken).ConfigureAwait(false); + + if (resource.AzureResource is not AzureBicepResource bicepResource) + { + // Non-Bicep Azure resources are not deployed by this controller, but they may still + // appear in dependency graphs. Complete their TCS so downstream resources do not wait + // forever for a deployment that will never run here. + CompleteProvisioning(resource.AzureResource); + resourceLogger.LogInformation("Skipping {resourceName} because it is not a Bicep resource.", resource.AzureResource.Name); + return; + } + + if (bicepResource.IsContainer() || bicepResource.IsEmulator()) + { + // Local emulators/container-backed resources are represented by Azure resource types + // but are started by DCP/container orchestration, not ARM provisioning. + CompleteProvisioning(resource.AzureResource); + resourceLogger.LogInformation("Skipping {resourceName} because it is not configured to be provisioned.", resource.AzureResource.Name); + } + else + { + var executionContext = serviceProvider.GetRequiredService(); + await WaitForProvisioningDependenciesAsync(bicepResource, executionContext, cancellationToken).ConfigureAwait(false); + + if (await IsMissingCachedResourceAsync(bicepResource, cancellationToken).ConfigureAwait(false)) + { + // Cached state can survive a user deleting the Azure resource outside Aspire. + // Clear it before ConfigureResourceAsync so the provisioner creates a fresh + // deployment rather than trusting outputs that point at a missing resource. + resourceLogger.LogWarning("Cached Azure deployment state for {resourceName} points to a missing Azure resource. Reprovisioning.", resource.AzureResource.Name); + await ClearCachedDeploymentStateAsync( + bicepResource, + preserveOverrides: true, + environmentLocation: (await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)).Location, + currentLocationOverride: null, + preserveInferredLocationOverrides: true, + cancellationToken).ConfigureAwait(false); + } + + if (await bicepProvisioner.ConfigureResourceAsync(bicepResource, cancellationToken).ConfigureAwait(false)) + { + // ConfigureResourceAsync returns true when existing local configuration is enough + // to satisfy the resource. Complete the TCS and publish connection-string events + // without creating or touching ARM resources. + CompleteProvisioning(resource.AzureResource); + resourceLogger.LogInformation("Using connection information stored in user secrets for {resourceName}.", resource.AzureResource.Name); + await PublishConnectionStringAvailableEventAsync(resource.Resource, parentChildLookup, cancellationToken).ConfigureAwait(false); + } + else + { + if (resource.AzureResource.IsExisting()) + { + resourceLogger.LogInformation("Resolving {resourceName} as existing resource...", resource.AzureResource.Name); + } + else + { + resourceLogger.LogInformation("Provisioning {resourceName}...", resource.AzureResource.Name); + } + + var provisioningContext = await provisioningContextLazy.Value.ConfigureAwait(false); + + // The provisioner owns Bicep compilation, state persistence, and ARM operations. + // The controller is responsible for sequencing, cancellation, and publishing the + // resource lifecycle around this call. + await bicepProvisioner.GetOrCreateResourceAsync( + bicepResource, + provisioningContext, + cancellationToken).ConfigureAwait(false); + + CompleteProvisioning(resource.AzureResource); + await PublishConnectionStringAvailableEventAsync(resource.Resource, parentChildLookup, cancellationToken).ConfigureAwait(false); + } + } + } + catch (AzureCliNotOnPathException ex) + { + resourceLogger.LogCritical("Using Azure resources during local development requires the installation of the Azure CLI. See https://aka.ms/dotnet/aspire/azcli for instructions."); + FailProvisioning(resource.AzureResource, ex); + } + catch (MissingConfigurationException ex) + { + resourceLogger.LogCritical("Resource could not be provisioned because Azure subscription, location, and resource group information is missing. See https://aka.ms/dotnet/aspire/azure/provisioning for more details."); + FailProvisioning(resource.AzureResource, ex); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested || ex.CancellationToken == cancellationToken) + { + CancelProvisioning(resource.AzureResource, cancellationToken.IsCancellationRequested ? cancellationToken : ex.CancellationToken); + } + catch (Exception ex) + { + resourceLogger.LogError(ex, "Error provisioning {ResourceName}.", resource.AzureResource.Name); + FailProvisioning(resource.AzureResource, new InvalidOperationException($"Unable to provision {resource.AzureResource.Name}.", ex)); + } + } + + private static void CompleteProvisioning(IAzureResource resource) + { + resource.ProvisioningTaskCompletionSource?.TrySetResult(); + } + + private static void FailProvisioning(IAzureResource resource, Exception exception) + { + resource.ProvisioningTaskCompletionSource?.TrySetException(exception); + } + + private static void CancelProvisioning(IAzureResource resource, CancellationToken cancellationToken) + { + resource.ProvisioningTaskCompletionSource?.TrySetCanceled(cancellationToken); + } + + private static async Task WaitForProvisioningDependenciesAsync( + AzureBicepResource resource, + DistributedApplicationExecutionContext executionContext, + CancellationToken cancellationToken) + { + // Force template generation before dependency discovery. Some resources populate parameter + // values and references lazily while generating Bicep. + _ = resource.GetBicepTemplateString(); + + var dependencies = new HashSet(); + var discoveredDependencies = await resource.GetResourceDependenciesAsync( + executionContext, + ResourceDependencyDiscoveryMode.Recursive, + cancellationToken).ConfigureAwait(false); + + dependencies.UnionWith(discoveredDependencies.OfType()); + + foreach (var parameter in resource.Parameters.Values) + { + // Parameters and references can contain nested IValueWithReferences instances that are + // not visible from the app model graph. Include them so provisioning waits for resources + // referenced only through generated Bicep values. + CollectProvisioningDependencies(dependencies, parameter); + } + + foreach (var reference in resource.References) + { + CollectProvisioningDependencies(dependencies, reference); + } + + await Task.WhenAll(dependencies + .Where(dependency => !ReferenceEquals(dependency, resource)) + .Select(dependency => dependency.ProvisioningTaskCompletionSource?.Task.WaitAsync(cancellationToken)) + .OfType()).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)) + { + // Values can reference each other. Track visited objects by reference to avoid infinite + // recursion while walking IValueWithReferences graphs. + return; + } + + if (value is IAzureResource azureResource) + { + dependencies.Add(azureResource); + } + + if (value is IValueWithReferences valueWithReferences) + { + foreach (var reference in valueWithReferences.References) + { + CollectProvisioningDependencies(dependencies, reference, visited); + } + } + } + + private async Task PublishConnectionStringAvailableEventAsync( + IResource targetResource, + ILookup parentChildLookup, + CancellationToken cancellationToken) + { + if (targetResource is IResourceWithConnectionString) + { + // Connection string events unblock resources that resolve connection strings lazily after + // Azure outputs are available. + var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(targetResource, serviceProvider); + await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false); + } + + if (parentChildLookup[targetResource] is { } children) + { + foreach (var child in children.OfType().Where(static c => c is IResourceWithParent)) + { + // Child resources such as databases often derive their connection strings from a + // parent Azure account. Recurse so children observe the same availability signal. + await PublishConnectionStringAvailableEventAsync(child, parentChildLookup, cancellationToken).ConfigureAwait(false); + } + } + } + + private static ImmutableArray 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 cancellationToken) + { + if (model.Resources.OfType().SingleOrDefault() is not { } azureEnvironmentResource) + { + return; + } + + var azureEnvironmentProperties = state.Text == KnownResourceStates.NotStarted + ? ImmutableArray.Empty + : BuildAzureEnvironmentProperties(await GetCurrentAzureContextAsync(cancellationToken).ConfigureAwait(false)); + + // NotStarted represents "no active Azure context for this run" after reset/delete/forget. + // Strip Azure-specific properties and URLs so the dashboard does not show stale subscription, + // resource group, or portal links from the previous context. + await notificationService.PublishUpdateAsync(azureEnvironmentResource, existingState => existingState with + { + State = state, + Properties = state.Text == KnownResourceStates.NotStarted + ? FilterProperties(existingState.Properties) + : FilterProperties(existingState.Properties).SetResourcePropertyRange(azureEnvironmentProperties), + Urls = state.Text == KnownResourceStates.NotStarted ? [] : existingState.Urls, + CreationTimeStamp = state.Text == KnownResourceStates.NotStarted ? null : existingState.CreationTimeStamp, + StartTimeStamp = state.Text == KnownResourceStates.NotStarted ? null : existingState.StartTimeStamp, + StopTimeStamp = state.Text == KnownResourceStates.NotStarted ? null : existingState.StopTimeStamp + }).ConfigureAwait(false); + + if (state.Text == KnownResourceStates.NotStarted) + { + loggerService.GetLogger(azureEnvironmentResource).LogInformation("Azure provisioning state has been reset."); + } + } + + private static ImmutableArray BuildAzureEnvironmentProperties(AzureContextState context) + { + var properties = ImmutableArray.Empty; + + if (!string.IsNullOrEmpty(context.SubscriptionId)) + { + properties = properties.SetResourceProperty("azure.subscription.id", context.SubscriptionId); + } + + if (!string.IsNullOrEmpty(context.ResourceGroup)) + { + properties = properties.SetResourceProperty("azure.resource.group", context.ResourceGroup); + } + + if (!string.IsNullOrEmpty(context.Location)) + { + properties = properties.SetResourceProperty("azure.location", context.Location); + } + + if (!string.IsNullOrEmpty(context.TenantId)) + { + properties = properties.SetResourceProperty("azure.tenant.id", context.TenantId); + } + + return properties; + } + + private sealed class AzureOperationState(string displayName, bool isAllResources, IReadOnlySet resourceNames) + { + // Read under _operationStateLock on the hot command-state path, so it holds only what command + // enablement needs: target resource names and whether the operation affects all resources. + public string DisplayName { get; } = displayName; + public bool IsAllResources { get; } = isAllResources; + public IReadOnlySet ResourceNames { get; } = resourceNames; + + public static AzureOperationState None { get; } = new(string.Empty, false, new HashSet(StringComparers.ResourceName)); + + public static AzureOperationState All(string displayName) => new(displayName, true, new HashSet(StringComparers.ResourceName)); + + public static AzureOperationState Resource(string resourceName, string displayName) => new(displayName, false, new HashSet([resourceName], StringComparers.ResourceName)); + } + + private sealed record AzureControllerState(AzureControllerStatus Status) + { + public static AzureControllerState Empty { get; } = new(new AzureControllerStatus(null)); + } + + private sealed record AzureControllerStatus(AzureIntent? CurrentIntent); + + private sealed record DeleteAzureResourceResult(IReadOnlyList ResourceIds); + + internal enum AzureEnvironmentCommand + { + ResetProvisioningState, + ChangeAzureContext, + ReprovisionAll, + DeleteAzureResources + } + + internal enum AzureResourceCommand + { + ChangeLocation, + GetAzureResource, + CancelDeployment, + DeleteAzureResource, + ForgetState, + Reprovision + } + + internal sealed record EnvironmentCommandDefinition( + AzureEnvironmentCommand Command, + string Name, + string DisplayName, + string Description, + string ConfirmationMessage, + string IconName, + IconVariant IconVariant, + bool IsHighlighted, + Func> ExecuteCommand, + IReadOnlyList? Arguments = null, + Func? ValidateArguments = null); + + internal sealed record ResourceCommandDefinition( + AzureResourceCommand Command, + string Name, + string DisplayName, + string Description, + string? ConfirmationMessage, + string IconName, + IconVariant IconVariant, + bool IsHighlighted, + Func> ExecuteCommand, + IReadOnlyList? Arguments = null, + Func? ValidateArguments = null); + + // Intents are the messages consumed by the single-reader operation loop. Keeping them as records + // makes each queued operation self-describing for command-state calculation and execution. + private abstract record AzureIntent(AzureOperationState Operation); + + private sealed record ResetStateIntent() : AzureIntent(AzureOperationState.All("Reset provisioning state")); + + private sealed record ChangeAzureContextIntent(AzureProvisioningOptionsUpdate? Options) : 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 CancelResourceDeploymentIntent(string ResourceName) : AzureIntent(AzureOperationState.Resource(ResourceName, "Cancel Azure deployment")); + + private sealed record DeleteAzureResourceIntent(string ResourceName) : AzureIntent(AzureOperationState.Resource(ResourceName, "Delete Azure resource")); + + // Drift checks use AzureOperationState.None so they serialize with commands without disabling + // dashboard command buttons. + private sealed record DetectDriftIntent() : AzureIntent(AzureOperationState.None); + + private sealed record QueuedOperation( + DistributedApplicationModel Model, + AzureIntent Intent, + TaskCompletionSource Completion, + CancellationToken CancellationToken); + + private sealed record AzureContextState(string? SubscriptionId, string? ResourceGroup, string? Location, string? TenantId); +} diff --git a/src/Aspire.Hosting.Azure/AzureProvisioningJsonHelpers.cs b/src/Aspire.Hosting.Azure/AzureProvisioningJsonHelpers.cs new file mode 100644 index 00000000000..2b04da4f8f1 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzureProvisioningJsonHelpers.cs @@ -0,0 +1,37 @@ +// 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.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Aspire.Hosting.Azure; + +/// +/// JSON helpers for Azure provisioning command payloads and persisted deployment state. +/// +internal static class AzureProvisioningJsonHelpers +{ + private static readonly JsonDocumentOptions s_deploymentStateJsonDocumentOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }; + + private static readonly JsonSerializerOptions s_commandResultJsonSerializerOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + internal static JsonNode? ParseDeploymentStateJson(string json) + { + // Deployment state stores JSON payloads as strings, for example: + // Outputs = { "id": { "type": "String", "value": "/subscriptions/..." } } + // These values can be hand-edited while recovering local state, so tolerate + // JSONC-style comments and trailing commas when reading cached state. + return JsonNode.Parse(json, documentOptions: s_deploymentStateJsonDocumentOptions); + } + + internal static string ToCommandResultJsonString(JsonObject json) + => json.ToJsonString(s_commandResultJsonSerializerOptions); +} diff --git a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs index 02448c02bb5..7955772d82f 100644 --- a/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs +++ b/src/Aspire.Hosting.Azure/AzureResourcePreparer.cs @@ -7,6 +7,7 @@ using Aspire.Hosting.ApplicationModel; using Azure.Provisioning; using Azure.Provisioning.Authorization; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure; @@ -20,6 +21,11 @@ internal sealed class AzureResourcePreparer( IOptions options, DistributedApplicationExecutionContext executionContext) { + internal Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken) + { + return PrepareResourcesAsync(@event.Model, cancellationToken); + } + internal async Task PrepareResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken) { var azureResources = GetAzureResourcesFromAppModel(model); @@ -37,6 +43,11 @@ internal async Task PrepareResourcesAsync(DistributedApplicationModel model, Can await BuildRoleAssignmentAnnotations(model, azureResources, cancellationToken).ConfigureAwait(false); + if (executionContext.IsRunMode) + { + AddPerResourceCommands(azureResources); + } + // set the ProvisioningBuildOptions on the resource, if necessary foreach (var r in azureResources) { @@ -47,6 +58,71 @@ internal async Task PrepareResourcesAsync(DistributedApplicationModel model, Can } } + 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 => command.ExecuteCommand(context.Services.GetRequiredService(), resource.Resource.Name, context), + new CommandOptions + { + Description = command.Description, + ConfirmationMessage = command.ConfirmationMessage, + IconName = command.IconName, + IconVariant = command.IconVariant, + IsHighlighted = command.IsHighlighted, + Arguments = command.Command == AzureProvisioningController.AzureResourceCommand.ChangeLocation + ? AzureProvisioningController.CreateChangeLocationCommandArguments(GetDeploymentStateResourceName(resource)) + : command.Arguments ?? [], + ValidateArguments = command.ValidateArguments, + UpdateState = context => context.Services.GetRequiredService().GetResourceCommandState(resource.Resource.Name, command.Command, context) + }); + } + } + } + + private static string GetDeploymentStateResourceName((IResource Resource, IAzureResource AzureResource) resource) + => resource.AzureResource is AzureBicepResource bicepResource ? bicepResource.Name : 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.Arguments, + commandOptions.ConfirmationMessage, + commandOptions.IconName, + commandOptions.IconVariant, + commandOptions.IsHighlighted, + commandOptions.Visibility, + commandOptions.ValidateArguments)); + } + 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 c94500025b5..3969abbe502 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerExtensions.cs @@ -64,8 +64,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 is resolved by the run-mode + // pipeline step. 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..252c071fe5b 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/BicepUtilities.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/BicepUtilities.cs @@ -1,11 +1,15 @@ // 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; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Azure.Provisioning; @@ -14,6 +18,12 @@ namespace Aspire.Hosting.Azure.Provisioning; /// internal static class BicepUtilities { + internal const string DeploymentStateIdKey = "Id"; + internal const string DeploymentStateParametersKey = "Parameters"; + internal const string DeploymentStateOutputsKey = "Outputs"; + internal const string DeploymentStateScopeKey = "Scope"; + internal const string DeploymentStateChecksumKey = "CheckSum"; + // Known values since they will be filled in by the provisioner private static readonly string[] s_knownParameterNames = [ @@ -105,7 +115,7 @@ public static string GetChecksum(AzureBicepResource resource, JsonObject paramet public static async ValueTask GetCurrentChecksumAsync(AzureBicepResource resource, IConfiguration section, CancellationToken cancellationToken = default) { // Fill in parameters from configuration - if (section["Parameters"] is not string jsonString) + if (section[DeploymentStateParametersKey] is not string jsonString) { return null; } @@ -113,7 +123,7 @@ public static string GetChecksum(AzureBicepResource resource, JsonObject paramet try { var parameters = JsonNode.Parse(jsonString)?.AsObject(); - var scope = section["Scope"] is string scopeString + var scope = section[DeploymentStateScopeKey] is string scopeString ? JsonNode.Parse(scopeString)?.AsObject() : null; @@ -142,6 +152,45 @@ 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, ILogger logger, CancellationToken cancellationToken = default) + { + if (section.Data[DeploymentStateParametersKey]?.GetValue() is not { Length: > 0 } jsonString) + { + return null; + } + + try + { + var parameters = JsonNode.Parse(jsonString)?.AsObject(); + var scope = section.Data[DeploymentStateScopeKey]?.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 (Exception ex) + { + logger.LogDebug(ex, "Unable to compute current checksum for resource {ResourceName}.", resource.Name); + 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 61aa686b559..dbaf86beb6d 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs @@ -35,17 +35,31 @@ internal abstract partial class BaseProvisioningContextProvider( protected readonly IInteractionService _interactionService = interactionService; protected readonly AzureProvisionerOptions _options = options.Value; + + // Run mode rehydrates the singleton options instance by starting from the configured + // values and then overlaying deployment-state values. Keep a baseline for the complete + // set of provisioning values that rehydration resets before applying saved state. + 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_\-\.\(\)]+$")] private static partial Regex ResourceGroupValidCharacters(); - protected static bool IsValidResourceGroupName(string? name) + protected internal static bool IsValidResourceGroupName(string? name) { if (string.IsNullOrWhiteSpace(name) || name.Length > 90) { @@ -98,7 +112,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; @@ -168,21 +182,12 @@ 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 - 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); + await SaveProvisioningOptionsAsync( + resourceGroupName, + tenantResource.TenantId?.ToString() ?? _options.TenantId, + tenantResource.DefaultDomain, + azureStateSection, + cancellationToken).ConfigureAwait(false); return new ProvisioningContext( credential, @@ -197,6 +202,50 @@ public virtual async Task CreateProvisioningContextAsync(Ca 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, _options.TenantId, tenantDomain: null, azureStateSection, cancellationToken).ConfigureAwait(false); + } + + private async Task SaveProvisioningOptionsAsync( + string resourceGroupName, + string? tenantId, + string? tenantDomain, + DeploymentStateSection azureStateSection, + CancellationToken cancellationToken) + { + var azureSection = azureStateSection.Data; + azureSection["Location"] = _options.Location; + azureSection["SubscriptionId"] = _options.SubscriptionId; + azureSection["ResourceGroup"] = resourceGroupName; + + if (!string.IsNullOrEmpty(tenantId)) + { + azureSection["TenantId"] = tenantId; + } + else + { + azureSection.Remove("TenantId"); + } + + if (!string.IsNullOrEmpty(tenantDomain)) + { + azureSection["Tenant"] = tenantDomain; + } + else + { + azureSection.Remove("Tenant"); + } + + if (_options.AllowResourceGroupCreation.HasValue) + { + azureSection["AllowResourceGroupCreation"] = _options.AllowResourceGroupCreation.Value; + } + + await _deploymentStateManager.SaveSectionAsync(azureStateSection, cancellationToken).ConfigureAwait(false); + } + 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..5c23888196d 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/BicepCompiler.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/BicepCompiler.cs @@ -45,6 +45,7 @@ public async Task CompileBicepToArmAsync(string bicepFilePath, Cancellat } var armTemplateContents = new StringBuilder(); + var errorContents = new StringBuilder(); var templateSpec = new ProcessSpec(commandPath) { Arguments = arguments, @@ -56,17 +57,35 @@ public async Task CompileBicepToArmAsync(string bicepFilePath, Cancellat OnErrorData = data => { _logger.LogDebug("{CommandPath} (stderr): {Error}", commandPath, data); + errorContents.AppendLine(data); }, }; _logger.LogDebug("Running {CommandPath} with arguments: {Arguments}", commandPath, arguments); - var exitCode = await ExecuteCommand(templateSpec).ConfigureAwait(false); + int exitCode; + try + { + 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); + } if (exitCode != 0) { _logger.LogError("Bicep compilation for {BicepFilePath} failed with exit code {ExitCode}.", bicepFilePath, exitCode); - throw new InvalidOperationException($"Failed to compile bicep file: {bicepFilePath}"); + 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}"); } _logger.LogDebug("Bicep compilation for {BicepFilePath} succeeded.", bicepFilePath); diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs index 9f9f380d14d..0ef5a53448a 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; +using Azure; using Azure.Core; using Azure.ResourceManager; using Azure.ResourceManager.Authorization; @@ -127,6 +129,45 @@ public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) return new DefaultRoleAssignmentCollection(armClient.GetRoleAssignments(scope)); } + 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); + } + + public async Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + { + var deployment = armClient.GetArmDeploymentResource(new ResourceIdentifier(deploymentId)); + await deployment.CancelAsync(cancellationToken).ConfigureAwait(false); + } + + public async IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var deployment = armClient.GetArmDeploymentResource(new ResourceIdentifier(deploymentId)); + + await foreach (var operation in deployment.GetDeploymentOperationsAsync(top: null, cancellationToken).ConfigureAwait(false)) + { + if (operation.Properties.TargetResource?.Id is { Length: > 0 } resourceId) + { + yield return resourceId; + } + } + } + private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource { public Guid? TenantId => tenantResource.Data.TenantId; diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs index c948ddf6d91..150c938a5fb 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmDeploymentCollection.cs @@ -18,4 +18,10 @@ public Task> CreateOrUpdateAsync( { return armDeploymentCollection.CreateOrUpdateAsync(waitUntil, deploymentName, content, cancellationToken); } + + public async Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) + { + var deployment = await armDeploymentCollection.GetAsync(deploymentName, cancellationToken).ConfigureAwait(false); + await deployment.Value.CancelAsync(cancellationToken).ConfigureAwait(false); + } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs index fe35e620203..688f2693717 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultResourceGroupResource.cs @@ -3,6 +3,7 @@ using Azure; using Azure.Core; +using Azure.ResourceManager; using Azure.ResourceManager.Resources; namespace Aspire.Hosting.Azure.Provisioning.Internal; @@ -20,10 +21,8 @@ public IArmDeploymentCollection GetArmDeployments() return new DefaultArmDeploymentCollection(resourceGroupResource.GetArmDeployments()); } - public async Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) - { - await resourceGroupResource.DeleteAsync(waitUntil, cancellationToken: cancellationToken).ConfigureAwait(false); - } + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) => + resourceGroupResource.DeleteAsync(waitUntil, cancellationToken: cancellationToken); public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 36e1527f1cf..a0a9252adc6 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -63,6 +63,55 @@ 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); + + /// + /// Applies provisioning option values and persists the resulting Azure context. + /// + /// The option values to apply. + /// The cancellation token. + /// The persisted provisioning options. + Task ApplyProvisioningOptionsAsync(AzureProvisioningOptionsUpdate options, CancellationToken cancellationToken = default); +} + +/// +/// Azure provisioning option values supplied by a command. +/// +internal sealed record AzureProvisioningOptionsUpdate(string? SubscriptionId, string? ResourceGroup, string? Location, string? TenantId); + +/// +/// The currently persisted Azure provisioning options. +/// +internal sealed record AzureProvisioningOptionsState(string? SubscriptionId, string? ResourceGroup, string? Location, string? TenantId); + +/// +/// 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; + public Task ApplyProvisioningOptionsAsync(AzureProvisioningOptionsUpdate options, CancellationToken cancellationToken = default) + => Task.FromResult(new AzureProvisioningOptionsState(options.SubscriptionId, options.ResourceGroup, options.Location, options.TenantId)); +} + /// /// Abstraction for Azure ArmClient. /// @@ -102,6 +151,26 @@ internal interface IArmClient /// Gets role assignments collection for the specified scope. /// IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope); + + /// + /// 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); + + /// + /// Cancels the specified Azure deployment. + /// + Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default); + + /// + /// Gets Azure resource IDs targeted by the specified deployment. + /// + IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, CancellationToken cancellationToken = default); } /// @@ -174,7 +243,7 @@ internal interface IResourceGroupResource /// /// Deletes the resource group. /// - Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default); + Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default); /// /// Lists all resources in the resource group. @@ -211,6 +280,11 @@ Task> CreateOrUpdateAsync( string deploymentName, ArmDeploymentContent content, CancellationToken cancellationToken = default); + + /// + /// Cancels a running deployment. + /// + Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default); } /// diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs index 1b1b1dd5eff..33b72b078cf 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs @@ -3,8 +3,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; using Aspire.Hosting.Azure.Resources; using Aspire.Hosting.Azure.Utils; using Aspire.Hosting.Pipelines; @@ -35,9 +36,12 @@ internal sealed class RunModeProvisioningContextProvider( userPrincipalProvider, tokenCredentialProvider, deploymentStateManager, - distributedApplicationExecutionContext) + distributedApplicationExecutionContext), IAzureProvisioningOptionsManager, IDisposable { - private readonly TaskCompletionSource _provisioningOptionsAvailable = new(TaskCreationOptions.RunContinuationsAsynchronously); + // Serialize provisioning option updates because the dashboard command path can invoke + // prompt/apply concurrently, and both paths rehydrate and mutate the shared options + // instance before saving deployment state. + private readonly SemaphoreSlim _provisioningOptionsLock = new(1, 1); protected override string GetDefaultResourceGroupName() { @@ -62,237 +66,217 @@ 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) + { + if (string.IsNullOrEmpty(_options.ResourceGroup)) + { + _options.ResourceGroup = GetDefaultResourceGroupName(); + _options.AllowResourceGroupCreation ??= true; + } + + await SaveProvisioningOptionsAsync(_options.ResourceGroup, cancellationToken).ConfigureAwait(false); + } + + public async Task ApplyProvisioningOptionsAsync(AzureProvisioningOptionsUpdate options, CancellationToken cancellationToken = default) { - // Caches for dynamic loading callbacks to avoid redundant Azure API calls. - // Keyed by the relevant parameter (tenant ID, subscription ID) so they invalidate when the dependency changes. - var subscriptionsCache = new ConcurrentDictionary>? subscriptionOptions, bool fetchSucceeded)>>(); - var resourceGroupsCache = new ConcurrentDictionary? resourceGroupOptions, bool fetchSucceeded)>>(); - var locationsCache = new ConcurrentDictionary> locationOptions, bool fetchSucceeded)>>(); - - // Helper to cache only successful results. Failed tasks are evicted so the next callback retries. - static async Task<(T options, bool fetchSucceeded)> GetOrAddAsync( - ConcurrentDictionary> cache, - string key, - Func> valueFactory) + await _provisioningOptionsLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { - var cachedTask = cache.GetOrAdd(key, valueFactory); - var result = await cachedTask.ConfigureAwait(false); + await RehydrateProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); - if (!result.fetchSucceeded) + if (!string.IsNullOrWhiteSpace(options.SubscriptionId)) { - cache.TryRemove(new KeyValuePair>(key, cachedTask)); + _options.SubscriptionId = options.SubscriptionId; } - return result; - } + if (!string.IsNullOrWhiteSpace(options.ResourceGroup)) + { + _options.ResourceGroup = options.ResourceGroup; + } - while (_options.Location == null || _options.SubscriptionId == null) - { - var messageBarResult = await _interactionService.PromptNotificationAsync( - AzureProvisioningStrings.NotificationTitle, - AzureProvisioningStrings.NotificationMessage, - new NotificationInteractionOptions - { - Intent = MessageIntent.Warning, - PrimaryButtonText = AzureProvisioningStrings.NotificationPrimaryButtonText - }, - cancellationToken) - .ConfigureAwait(false); - - if (messageBarResult.Canceled) + if (!string.IsNullOrWhiteSpace(options.Location)) { - // User canceled the prompt, so we exit the loop - _provisioningOptionsAvailable.SetException(new MissingConfigurationException("Azure provisioning options were not provided.")); - return; + _options.Location = options.Location; } - if (messageBarResult.Data) + _options.TenantId = string.IsNullOrWhiteSpace(options.TenantId) ? null : options.TenantId; + + _options.AllowResourceGroupCreation = true; + + if (!HasProvisioningOptions()) { - var inputs = new List(); + throw new MissingConfigurationException("Azure provisioning options were not provided."); + } - // 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); + await PersistProvisioningOptionsAsync(cancellationToken).ConfigureAwait(false); - context.Input.Options = fetchSucceeded - ? tenantOptions! - : []; - } - } - }); - } + return new AzureProvisioningOptionsState( + _options.SubscriptionId, + _options.ResourceGroup, + _options.Location, + _options.TenantId); + } + finally + { + _provisioningOptionsLock.Release(); + } + } - // 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 + private bool HasProvisioningOptions() => + !string.IsNullOrEmpty(_options.Location) && + !string.IsNullOrEmpty(_options.SubscriptionId); - 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 GetOrAddAsync(subscriptionsCache, tenantId, key => TryGetSubscriptionsAsync(key, cancellationToken)).ConfigureAwait(false); - - context.Input.Options = fetchSucceeded - ? subscriptionOptions! - : []; - context.Input.Disabled = false; - }, - DependsOnInputs = [TenantName] - }; - } + public void Dispose() + { + _provisioningOptionsLock.Dispose(); + } - 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; + private async Task RehydrateProvisioningOptionsAsync(CancellationToken cancellationToken) + { + var azureSection = await _deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); - var (resourceGroupOptions, fetchSucceeded) = await GetOrAddAsync(resourceGroupsCache, subscriptionId, key => TryGetResourceGroupsWithLocationAsync(key, cancellationToken)).ConfigureAwait(false); + _options.ResourceGroupPrefix = _configuredOptions.ResourceGroupPrefix; + _options.AllowResourceGroupCreation = _configuredOptions.AllowResourceGroupCreation; + _options.Location = _configuredOptions.Location; + _options.SubscriptionId = _configuredOptions.SubscriptionId; + _options.ResourceGroup = _configuredOptions.ResourceGroup; + _options.TenantId = _configuredOptions.TenantId; - if (fetchSucceeded && resourceGroupOptions is not null) - { - context.Input.Options = resourceGroupOptions.Select(rg => KeyValuePair.Create(rg.Name, rg.Name)).ToList(); - } - else - { - context.Input.Options = []; + var data = azureSection.Data; + if (data["Location"]?.GetValue() is { Length: > 0 } location) + { + _options.Location = location; + } - // 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["SubscriptionId"]?.GetValue() is { Length: > 0 } subscriptionId) + { + _options.SubscriptionId = subscriptionId; + } - inputs.Add(new InteractionInput - { - 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; + if (data["ResourceGroup"]?.GetValue() is { Length: > 0 } resourceGroup) + { + _options.ResourceGroup = resourceGroup; + } + + if (data["TenantId"]?.GetValue() is { Length: > 0 } tenantId) + { + _options.TenantId = tenantId; + } - // Check if the selected resource group is an existing one - var (resourceGroupOptions, fetchSucceeded) = await GetOrAddAsync(resourceGroupsCache, subscriptionId, key => TryGetResourceGroupsWithLocationAsync(key, cancellationToken)).ConfigureAwait(false); + if (TryGetBoolean(data["AllowResourceGroupCreation"]) is bool allowResourceGroupCreation) + { + _options.AllowResourceGroupCreation = allowResourceGroupCreation; + } + } - 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; - } - } + private static bool? TryGetBoolean(JsonNode? value) + { + return value?.GetValueKind() switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetValue(), out var parsedValue) => parsedValue, + _ => null + }; + } - // For new resource groups, load all locations - var (locationOptions, _) = await GetOrAddAsync(locationsCache, subscriptionId, key => TryGetLocationsAsync(key, cancellationToken)).ConfigureAwait(false); - context.Input.Options = locationOptions; - context.Input.Disabled = false; - }, - DependsOnInputs = [SubscriptionIdName, ResourceGroupName] - } - }); + 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) + { + return false; + } + + shouldPrompt = messageBarResult.Data; + } + if (shouldPrompt) + { + var inputs = CreateProvisioningInputs(forcePrompt, cancellationToken); var result = await _interactionService.PromptInputsAsync( AzureProvisioningStrings.InputsTitle, AzureProvisioningStrings.InputsMessage, @@ -328,21 +312,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. + } + }); + } - _provisioningOptionsAvailable.SetResult(); + 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 = []; + + 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 ce028f76d87..6cf242cd2db 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/AzureProvisioner.cs @@ -4,282 +4,15 @@ #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 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, - DistributedApplicationExecutionContext executionContext, - IProvisioningContextProvider provisioningContextProvider - ) + AzureProvisioningController provisioningController) { - internal const string AspireResourceNameTag = "aspire-resource-name"; - - private ILookup? _parentChildLookup; - - internal async Task ProvisionResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) + internal Task ProvisionResourcesAsync(DistributedApplicationModel model, CancellationToken cancellationToken = default) { - // Only run in RunMode - if (!executionContext.IsRunMode) - { - return; - } - - var azureResources = AzureResourcePreparer.GetAzureResourcesFromAppModel(model); - if (azureResources.Count == 0) - { - return; - } - - // Create a map of parents to their children used to propagate state changes later. - _parentChildLookup = 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/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/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); - } - } - } + return provisioningController.EnsureProvisionedAsync(model, cancellationToken); } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs index 4e70f3c4d66..1587c723272 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs @@ -11,8 +11,9 @@ using Aspire.Hosting.Pipelines; using Azure; using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; using Azure.ResourceManager.Resources.Models; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Azure.Provisioning; @@ -27,30 +28,50 @@ internal sealed class BicepProvisioner( IDeploymentStateManager deploymentStateManager, DistributedApplicationExecutionContext executionContext, IFileSystemService fileSystemService, - ILogger logger) : IBicepProvisioner + ILogger logger, + TimeProvider? timeProvider = null) : IBicepProvisioner { + internal const string DeploymentStateProvisioningStateKey = "ProvisioningState"; + internal const string DeploymentStateProvisioningStateRunning = "Running"; + internal const string DeploymentStateProvisioningStateCanceled = "Canceled"; + internal const string DeploymentStateProvisioningStateSucceeded = "Succeeded"; + + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + /// - public async Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) + public async Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) { - var section = configuration.GetSection($"Azure:Deployments:{resource.Name}"); + var stateSection = await deploymentStateManager.AcquireSectionAsync($"Azure:Deployments:{resource.Name}", cancellationToken).ConfigureAwait(false); + if (stateSection.Data.Count == 0) + { + return false; + } - if (!section.Exists()) + if (stateSection.Data[DeploymentStateProvisioningStateKey]?.GetValue() is { Length: > 0 } provisioningState && + !string.Equals(provisioningState, DeploymentStateProvisioningStateSucceeded, StringComparison.Ordinal)) { + logger.LogDebug("Cached deployment state for resource {ResourceName} is incomplete because provisioning state is {ProvisioningState}.", resource.Name, provisioningState); return false; } - var currentCheckSum = await BicepUtilities.GetCurrentChecksumAsync(resource, section, cancellationToken).ConfigureAwait(false); - var configCheckSum = section["CheckSum"]; + var currentCheckSum = await BicepUtilities.GetCurrentChecksumAsync(resource, stateSection, logger, cancellationToken).ConfigureAwait(false); + var configCheckSum = stateSection.Data[BicepUtilities.DeploymentStateChecksumKey]?.GetValue(); - if (currentCheckSum != configCheckSum) + if (string.IsNullOrEmpty(configCheckSum)) { - logger.LogDebug("Checksum mismatch for resource {ResourceName}. Expected: {ExpectedChecksum}, Actual: {ActualChecksum}", resource.Name, currentCheckSum, configCheckSum); + logger.LogDebug("Cached deployment state for resource {ResourceName} is incomplete because it is missing a checksum.", resource.Name); + return false; + } + + if (string.IsNullOrEmpty(currentCheckSum) || !string.Equals(currentCheckSum, configCheckSum, StringComparison.Ordinal)) + { + logger.LogDebug("Checksum mismatch for resource {ResourceName}. Expected cached checksum {ExpectedChecksum}, computed checksum {ActualChecksum}", resource.Name, configCheckSum, currentCheckSum); return false; } logger.LogDebug("Configuring resource {ResourceName} from existing deployment state.", resource.Name); - if (section["Outputs"] is string outputJson) + if (stateSection.Data[BicepUtilities.DeploymentStateOutputsKey]?.GetValue() is { Length: > 0 } outputJson) { JsonNode? outputObj = null; try @@ -83,23 +104,32 @@ 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; + ResourceIdentifier? deploymentResourceId = null; + if (stateSection.Data[BicepUtilities.DeploymentStateIdKey]?.GetValue() is { Length: > 0 } configuredDeploymentId && + ResourceIdentifier.TryParse(configuredDeploymentId, out var id) && id is not null) { + deploymentId = configuredDeploymentId; + deploymentResourceId = id; portalUrls.Add(new(Name: "deployment", Url: GetDeploymentUrl(id), IsInternal: false)); } + var azureContext = await GetCurrentAzureContextAsync(deploymentResourceId, cancellationToken).ConfigureAwait(false); + var configuredLocation = GetConfiguredLocation(stateSection, azureContext.Location); + 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", azureContext.SubscriptionId), + new("azure.resource.group", azureContext.ResourceGroup), + new("azure.tenant.id", azureContext.TenantId), + new("azure.tenant.domain", azureContext.TenantDomain), + new("azure.location", configuredLocation), + new(CustomResourceKnownProperties.Source, deploymentId) + ]); return state with { @@ -127,6 +157,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 +166,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 +199,22 @@ await notificationService.PublishUpdateAsync(resource, state => var scope = new JsonObject(); await BicepUtilities.SetScopeAsync(scope, resource, cancellationToken: cancellationToken).ConfigureAwait(false); + var isSubscriptionScopedDeployment = resource.Scope?.Subscription != null; + // Resources with a Subscription scope should use a subscription-level deployment. + var deployments = isSubscriptionScopedDeployment + ? context.Subscription.GetArmDeployments() + : resourceGroup.GetArmDeployments(); + var deploymentName = executionContext.IsPublishMode ? $"{resource.Name}-{_timeProvider.GetUtcNow().ToUnixTimeSeconds()}" : resource.Name; + var deploymentId = GetDeploymentId(context, resourceGroup, deploymentName, isSubscriptionScopedDeployment); + var checksum = BicepUtilities.GetChecksum(resource, parameters, scope); var sw = Stopwatch.StartNew(); await notificationService.PublishUpdateAsync(resource, state => { return state with { - State = new("Creating ARM Deployment", KnownResourceStateStyles.Info) + State = new(AzureProvisioningController.CreatingArmDeploymentState, KnownResourceStateStyles.Info), + Properties = state.Properties.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId.ToString()), }; }) .ConfigureAwait(false); @@ -180,22 +222,47 @@ 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), Parameters = BinaryData.FromObjectAsJson(parameters), DebugSettingDetailLevel = "ResponseContent" }); - var operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, deploymentName, deploymentContent, cancellationToken).ConfigureAwait(false); + ArmOperation operation; + try + { + operation = await deployments.CreateOrUpdateAsync(WaitUntil.Started, deploymentName, deploymentContent, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (context.ExecutionContext.IsRunMode && + await TryCancelDeploymentAsync(deployments, deploymentName, resourceLogger, treatMissingOrInactiveAsCanceled: false).ConfigureAwait(false)) + { + var sectionName = $"Azure:Deployments:{resource.Name}"; + var canceledStateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, CancellationToken.None).ConfigureAwait(false); + var canceledLocationOverride = canceledStateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue(); + UpdateDeploymentState(canceledStateSection, canceledLocationOverride, deploymentId, parameters, outputObj: null, scope, checksum, effectiveLocation, DeploymentStateProvisioningStateCanceled); + await deploymentStateManager.SaveSectionAsync(canceledStateSection, CancellationToken.None).ConfigureAwait(false); + } + + throw; + } + + var statePersistenceCancellationToken = context.ExecutionContext.IsRunMode ? CancellationToken.None : cancellationToken; + DeploymentStateSection? stateSection = null; + string? locationOverride = null; + + if (context.ExecutionContext.IsRunMode) + { + var sectionName = $"Azure:Deployments:{resource.Name}"; + stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, statePersistenceCancellationToken).ConfigureAwait(false); + locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue(); + UpdateDeploymentState(stateSection, locationOverride, deploymentId, parameters, outputObj: null, scope, checksum, effectiveLocation, DeploymentStateProvisioningStateRunning); + await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false); + } // Resolve the deployment URL before waiting for the operation to complete - var url = GetDeploymentUrl(context, resourceGroup, resource.Name); + var url = GetDeploymentUrl(deploymentId); resourceLogger.LogInformation("Deployment started: {Url}", url); @@ -203,13 +270,28 @@ await notificationService.PublishUpdateAsync(resource, state => { return state with { - State = new("Waiting for Deployment", KnownResourceStateStyles.Info), + State = new(AzureProvisioningController.WaitingForDeploymentState, KnownResourceStateStyles.Info), Urls = [.. state.Urls, new(Name: "deployment", Url: url, IsInternal: false)], + Properties = state.Properties.SetResourceProperty(CustomResourceKnownProperties.Source, deploymentId.ToString()), }; }) .ConfigureAwait(false); - await operation.WaitForCompletionAsync(cancellationToken).ConfigureAwait(false); + try + { + await operation.WaitForCompletionAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (await TryCancelDeploymentAsync(deployments, deploymentName, resourceLogger, treatMissingOrInactiveAsCanceled: true).ConfigureAwait(false) && + stateSection is not null) + { + UpdateDeploymentState(stateSection, locationOverride, deploymentId, parameters, outputObj: null, scope, checksum, effectiveLocation, DeploymentStateProvisioningStateCanceled); + await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false); + } + + throw; + } sw.Stop(); resourceLogger.LogInformation("Deployment of {Name} to {ResourceGroup} took {Elapsed}", resource.Name, resourceGroup.Name, sw.Elapsed); @@ -217,9 +299,9 @@ await notificationService.PublishUpdateAsync(resource, state => var deployment = operation.Value; - var outputs = deployment.Data.Properties.Outputs; + var provisioningState = deployment.Data.Properties.ProvisioningState; - if (deployment.Data.Properties.ProvisioningState == ResourcesProvisioningState.Succeeded) + if (provisioningState == ResourcesProvisioningState.Succeeded) { if (context.ExecutionContext.IsRunMode) { @@ -228,42 +310,28 @@ await notificationService.PublishUpdateAsync(resource, state => } else { - throw new InvalidOperationException($"Deployment of {resource.Name} to {resourceGroup.Name} failed with {deployment.Data.Properties.ProvisioningState}"); + if (stateSection is not null) + { + UpdateDeploymentState(stateSection, locationOverride, deployment.Id, parameters, outputObj: null, scope, checksum, effectiveLocation, provisioningState.ToString()); + await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false); + } + + throw new InvalidOperationException($"Deployment of {resource.Name} to {resourceGroup.Name} failed with {provisioningState}"); } // e.g. { "sqlServerName": { "type": "String", "value": "" }} + var outputs = deployment.Data.Properties.Outputs; var outputObj = outputs?.ToObjectFromJson(); - // 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); - - // Update deployment state for this specific resource - stateSection.Data.Clear(); - - // Save the deployment id to the configuration - stateSection.Data["Id"] = deployment.Id.ToString(); - - // Stash all parameters as a single JSON string - stateSection.Data["Parameters"] = parameters.ToJsonString(); - - if (outputObj is not null) - { - // Same for outputs - stateSection.Data["Outputs"] = outputObj.ToJsonString(); - } - - // Write resource scope to config for consistent checksums - if (scope is not null) + if (stateSection is null) { - stateSection.Data["Scope"] = scope.ToJsonString(); + var sectionName = $"Azure:Deployments:{resource.Name}"; + stateSection = await deploymentStateManager.AcquireSectionAsync(sectionName, statePersistenceCancellationToken).ConfigureAwait(false); + locationOverride = stateSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue(); } - // Save the checksum to the configuration - stateSection.Data["CheckSum"] = BicepUtilities.GetChecksum(resource, parameters, scope); - - // Save the section back to the deployment state manager - await deploymentStateManager.SaveSectionAsync(stateSection, cancellationToken).ConfigureAwait(false); + UpdateDeploymentState(stateSection, locationOverride, deployment.Id, parameters, outputObj, scope, checksum, effectiveLocation, provisioningState: null); + await deploymentStateManager.SaveSectionAsync(stateSection, statePersistenceCancellationToken).ConfigureAwait(false); if (outputObj is not null) { @@ -283,21 +351,89 @@ 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 { State = new("Provisioned", KnownResourceStateStyles.Success), - CreationTimeStamp = DateTime.UtcNow, + CreationTimeStamp = _timeProvider.GetUtcNow().UtcDateTime, Properties = properties }; }) .ConfigureAwait(false); } + private async Task TryCancelDeploymentAsync(IArmDeploymentCollection deployments, string deploymentName, ILogger resourceLogger, bool treatMissingOrInactiveAsCanceled) + { + try + { + await deployments.CancelAsync(deploymentName, CancellationToken.None).ConfigureAwait(false); + resourceLogger.LogInformation("Cancellation requested for Azure deployment {DeploymentName}.", deploymentName); + return true; + } + catch (RequestFailedException ex) when (treatMissingOrInactiveAsCanceled && (ex.Status == 404 || ex.Status == 409)) + { + logger.LogInformation(ex, "Azure deployment {DeploymentName} was already absent or no longer active during cancellation.", deploymentName); + resourceLogger.LogInformation("Azure deployment {DeploymentName} was already absent or no longer active during cancellation.", deploymentName); + return true; + } + catch (RequestFailedException ex) + { + logger.LogWarning(ex, "Failed to cancel Azure deployment {DeploymentName}.", deploymentName); + resourceLogger.LogWarning("Failed to cancel Azure deployment {DeploymentName}: {Message}", deploymentName, ex.Message); + return false; + } + } + + private static void UpdateDeploymentState( + DeploymentStateSection stateSection, + string? locationOverride, + ResourceIdentifier deploymentId, + JsonObject parameters, + JsonObject? outputObj, + JsonObject? scope, + string checksum, + string effectiveLocation, + string? provisioningState) + { + stateSection.Data.Clear(); + + // Only preserve a per-resource override when it still matches the resource we just deployed. This keeps + // run-mode reprovisioning sticky while allowing global context changes to clear stale overrides naturally. + if (!string.IsNullOrEmpty(locationOverride) && + string.Equals(locationOverride, effectiveLocation, StringComparison.OrdinalIgnoreCase)) + { + stateSection.Data[AzureProvisioningController.LocationOverrideKey] = locationOverride; + } + + stateSection.Data[BicepUtilities.DeploymentStateIdKey] = deploymentId.ToString(); + stateSection.Data[BicepUtilities.DeploymentStateParametersKey] = parameters.ToJsonString(); + + if (outputObj is not null) + { + stateSection.Data[BicepUtilities.DeploymentStateOutputsKey] = outputObj.ToJsonString(); + } + + if (scope is not null) + { + stateSection.Data[BicepUtilities.DeploymentStateScopeKey] = scope.ToJsonString(); + } + + stateSection.Data[BicepUtilities.DeploymentStateChecksumKey] = checksum; + + if (!string.IsNullOrEmpty(provisioningState)) + { + stateSection.Data[DeploymentStateProvisioningStateKey] = provisioningState; + } + } + private void ConfigureSecretResolver(IAzureKeyVaultResource kvr) { var resource = (AzureBicepResource)kvr; @@ -351,17 +487,67 @@ 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 static string GetDeploymentUrl(ProvisioningContext provisioningContext, IResourceGroupResource resourceGroup, string deploymentName) + public static string GetDeploymentUrl(ResourceIdentifier deploymentId) => + AzurePortalUrls.GetDeploymentUrl(deploymentId); + + private static ResourceIdentifier GetDeploymentId(ProvisioningContext provisioningContext, IResourceGroupResource resourceGroup, string deploymentName, bool isSubscriptionScopedDeployment) { - var subId = provisioningContext.Subscription.Id.ToString(); - var rgName = resourceGroup.Name; - return AzurePortalUrls.GetDeploymentUrl(subId, rgName, deploymentName); + var deploymentPath = isSubscriptionScopedDeployment + ? $"{provisioningContext.Subscription.Id}/providers/Microsoft.Resources/deployments/{deploymentName}" + : $"{provisioningContext.Subscription.Id}/resourceGroups/{resourceGroup.Name}/providers/Microsoft.Resources/deployments/{deploymentName}"; + + return new(deploymentPath); } - public static string GetDeploymentUrl(ResourceIdentifier deploymentId) => - AzurePortalUrls.GetDeploymentUrl(deploymentId); + private async Task GetCurrentAzureContextAsync(ResourceIdentifier? deploymentId, CancellationToken cancellationToken) + { + var section = await deploymentStateManager.AcquireSectionAsync("Azure", cancellationToken).ConfigureAwait(false); + + return new AzureContextState( + GetStateValue(section, "SubscriptionId") ?? deploymentId?.SubscriptionId, + deploymentId?.ResourceGroupName ?? GetStateValue(section, "ResourceGroup"), + GetStateValue(section, "TenantId"), + GetStateValue(section, "Tenant"), + GetStateValue(section, "Location")); + } + + private static string? GetStateValue(DeploymentStateSection section, string key) => + section.Data[key]?.GetValue() is { Length: > 0 } value ? value : null; + + private static string GetConfiguredLocation(DeploymentStateSection section, string? fallbackLocation) + { + if (section.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue() is { Length: > 0 } locationOverride) + { + return locationOverride; + } + + if (section.Data[BicepUtilities.DeploymentStateParametersKey]?.GetValue() is { Length: > 0 } parametersJson) + { + try + { + if (JsonNode.Parse(parametersJson)?[AzureBicepResource.KnownParameters.Location]?["value"]?.GetValue() is { Length: > 0 } configuredLocation) + { + return configuredLocation; + } + } + catch + { + } + } + + return fallbackLocation ?? string.Empty; + } + + private static string GetEffectiveLocation(AzureBicepResource resource, ProvisioningContext context) => + resource.Parameters.TryGetValue(AzureBicepResource.KnownParameters.Location, out var location) && location is not null + ? location.ToString() ?? context.Location.ToString() + : context.Location.ToString(); + + private sealed record AzureContextState(string? SubscriptionId, string? ResourceGroup, string? TenantId, string? TenantDomain, string? Location); } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs index 567fe6e3c1a..5655e21d9bf 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Provisioners/IBicepProvisioner.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Configuration; - namespace Aspire.Hosting.Azure.Provisioning; /// @@ -11,13 +9,12 @@ namespace Aspire.Hosting.Azure.Provisioning; internal interface IBicepProvisioner { /// - /// Configures an Azure Bicep resource from configuration settings. + /// Configures an Azure Bicep resource from persisted deployment state. /// - /// The configuration containing Azure deployment settings. /// The Azure Bicep resource to configure. /// A cancellation token to cancel the operation. /// A task that represents the asynchronous operation. The task result contains a value indicating whether the resource was successfully configured. - Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken); + Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken); /// /// Gets an existing resource or creates a new Azure Bicep resource. diff --git a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs index 8b400f7f5df..f2884b7442d 100644 --- a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs +++ b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs @@ -11,7 +11,7 @@ namespace Aspire.Hosting.Azure.Resources { using System; - + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -295,5 +295,365 @@ 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 Returns cached deployment state and live Azure existence information for this resource.. + /// + internal static string GetAzureResourceCommandDescription { + get { + return ResourceManager.GetString("GetAzureResourceCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Get Azure resource. + /// + internal static string GetAzureResourceCommandName { + get { + return ResourceManager.GetString("GetAzureResourceCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure resource information retrieved.. + /// + internal static string GetAzureResourceCommandSuccess { + get { + return ResourceManager.GetString("GetAzureResourceCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue?. + /// + internal static string CancelDeploymentCommandConfirmation { + get { + return ResourceManager.GetString("CancelDeploymentCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requests cancellation of the cached Azure deployment for this resource.. + /// + internal static string CancelDeploymentCommandDescription { + get { + return ResourceManager.GetString("CancelDeploymentCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancel deployment. + /// + internal static string CancelDeploymentCommandName { + get { + return ResourceManager.GetString("CancelDeploymentCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure deployment cancellation requested.. + /// + internal static string CancelDeploymentCommandSuccess { + get { + return ResourceManager.GetString("CancelDeploymentCommandSuccess", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue?. + /// + internal static string DeleteAzureResourceCommandConfirmation { + get { + return ResourceManager.GetString("DeleteAzureResourceCommandConfirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource.. + /// + internal static string DeleteAzureResourceCommandDescription { + get { + return ResourceManager.GetString("DeleteAzureResourceCommandDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Azure resource. + /// + internal static string DeleteAzureResourceCommandName { + get { + return ResourceManager.GetString("DeleteAzureResourceCommandName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Azure resources deleted and provisioning state reset.. + /// + internal static string DeleteAzureResourceCommandSuccess { + get { + return ResourceManager.GetString("DeleteAzureResourceCommandSuccess", 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 54e4e99b8b3..b7b2a9dab36 100644 --- a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx +++ b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx @@ -198,4 +198,124 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/aspire/azure/pro 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. + + + Get Azure resource + + + Returns cached deployment state and live Azure existence information for this resource. + + + Azure resource information retrieved. + + + Cancel deployment + + + Requests cancellation of the cached Azure deployment for this resource. + + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + Azure deployment cancellation requested. + + + Delete Azure resource + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + Azure resources deleted and provisioning state reset. + + + 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 74366415f5f..4553027bef2 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ Zjistěte více v [dokumentaci k nasazení Azure](https://aka.ms/aspire/azure/pr 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 b52c6767fbe..408606cf4e3 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,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 519fd9d2727..39382dc1e80 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,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 0c7a8374514..9eca9763e6a 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,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 799bd5a30da..80276c89021 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,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 c7acb1ba602..2ec350300b5 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/aspire/azure/pro 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 16a63672024..3e031f5b04a 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/aspire/azure/pro 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 d117526c3d5..01ef235989a 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,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 8ead3dbf602..aa80a1303d9 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,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,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 b73a2407bcf..9ee6357b6ce 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/aspire/azure/pro Подготовка 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 40566df1376..666dfd72a9e 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf @@ -2,6 +2,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ Daha fazla bilgi için [Azure sağlama belgelerine](https://aka.ms/aspire/azure/ 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 832e8aa584d..60a8adb1ff6 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,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/aspire/azure/pro 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 bb19099b9f4..67a622a3514 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,146 @@ + + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + This requests cancellation of the cached Azure deployment for this resource. Any Azure resources already created by the deployment are not deleted. Do you want to continue? + + + + Requests cancellation of the cached Azure deployment for this resource. + Requests cancellation of the cached Azure deployment for this resource. + + + + Cancel deployment + Cancel deployment + + + + Azure deployment cancellation requested. + Azure deployment cancellation requested. + + + + 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 cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + This cancels the cached Azure deployment, deletes Azure resources targeted by this resource's deployment, and clears cached deployment state. Do you want to continue? + + + + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + Cancels the cached Azure deployment, deletes Azure resources targeted by the deployment, and clears cached deployment state for this resource. + + + + Delete Azure resource + Delete Azure resource + + + + Azure resources deleted and provisioning state reset. + Azure resources deleted and provisioning state reset. + + + + 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. + + + + Returns cached deployment state and live Azure existence information for this resource. + Returns cached deployment state and live Azure existence information for this resource. + + + + Get Azure resource + Get Azure resource + + + + Azure resource information retrieved. + Azure resource information retrieved. + + The model contains Azure resources that require an Azure Subscription. @@ -51,6 +191,66 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/aspire/azure/pro 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/Backchannel/AuxiliaryBackchannelRpcTarget.cs b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs index f04ee41cca8..eee4b0d1935 100644 --- a/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs +++ b/src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs @@ -495,7 +495,10 @@ private static async Task WaitForRunningAsync(ResourceN var resourceEvent = await WaitForResourceEventAsync( notificationService, target, - re => re.Snapshot.State?.Text == KnownResourceStates.Running || KnownResourceStates.TerminalStates.Contains(re.Snapshot.State?.Text) || re.Snapshot.ExitCode is not null, + re => re.Snapshot.State?.Text == KnownResourceStates.Running || + KnownResourceStates.TerminalStates.Contains(re.Snapshot.State?.Text, StringComparers.ResourceState) || + string.Equals(re.Snapshot.State?.Style, KnownResourceStateStyles.Error, StringComparisons.ResourceState) || + re.Snapshot.ExitCode is not null, $"Resource '{target.DisplayName}' failed to reach the target state before the operation was cancelled.", cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs index 06d719b9df8..693c170c62a 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardServiceData.cs @@ -227,6 +227,11 @@ public static void ProcessInputs(IServiceProvider serviceProvider, ILogger logge // If we're processing updates because of a dependency change, check to see if this input is depended on. if (dependencyChange) { + // Response updates are sent by the dashboard when an input marked + // UpdateStateOnChange changes. Only queue dependents whose source value actually + // changed; otherwise a validation/update roundtrip for one field could repeatedly + // restart unrelated dynamic loads. Inputs that need an initial load before any user + // change must opt into AlwaysLoadOnStart. var dependentInputs = inputsInfo.Inputs.Where( i => i.DynamicLoading is { } dynamic && (dynamic.DependsOnInputs?.Any(d => string.Equals(modelInput.Name, d, StringComparisons.InteractionInputName)) ?? false)); diff --git a/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs index 54022be2e1a..7f9bb031e2e 100644 --- a/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/InputViewModelTests.cs @@ -287,6 +287,84 @@ public void SetInput_DisabledInputUsesIncomingValue() Assert.Equal("westus", viewModel.Value); } + [Fact] + public void SetInput_DisabledToEnabledInputUsesIncomingValue() + { + var input = new InteractionInput + { + Label = "Subscription", + InputType = InputType.Choice, + Placeholder = "Select subscription ID", + Disabled = true + }; + var viewModel = new InputViewModel(input); + + var updatedInput = new InteractionInput + { + Label = "Subscription", + InputType = InputType.Choice, + Placeholder = "Select subscription ID", + Value = "12345678-1234-1234-1234-123456789012" + }; + updatedInput.Options.Add("12345678-1234-1234-1234-123456789012", "Test Subscription"); + + viewModel.SetInput(updatedInput); + + Assert.False(viewModel.InputDisabled); + Assert.Equal("12345678-1234-1234-1234-123456789012", viewModel.Value); + } + + [Fact] + public void SetInput_EnabledToDisabledInputUsesIncomingValue() + { + var input = new InteractionInput + { + Label = "Location", + InputType = InputType.Choice, + Value = "local" + }; + var viewModel = new InputViewModel(input); + + var updatedInput = new InteractionInput + { + Label = "Location", + InputType = InputType.Choice, + Disabled = true, + Value = "server" + }; + + viewModel.SetInput(updatedInput); + + Assert.True(viewModel.InputDisabled); + Assert.Equal("server", viewModel.Value); + } + + [Fact] + public void SetInput_LoadingCompletionUsesIncomingValue() + { + var input = new InteractionInput + { + Label = "Subscription", + InputType = InputType.Choice, + Loading = true, + Value = "local" + }; + var viewModel = new InputViewModel(input); + + var updatedInput = new InteractionInput + { + Label = "Subscription", + InputType = InputType.Choice, + Value = "server" + }; + updatedInput.Options.Add("server", "Server"); + + viewModel.SetInput(updatedInput); + + Assert.False(viewModel.InputDisabled); + Assert.Equal("server", viewModel.Value); + } + [Fact] public void SetInput_EnabledInputPreservesLocalValue() { diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageRunModeTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageRunModeTests.cs new file mode 100644 index 00000000000..3f9cd456aa2 --- /dev/null +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AzureStorageRunModeTests.cs @@ -0,0 +1,258 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Tests.Utils; +using Aspire.Deployment.EndToEnd.Tests.Helpers; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Deployment.EndToEnd.Tests; + +/// +/// End-to-end tests for live Azure resources provisioned by aspire start. +/// +public sealed class AzureStorageRunModeTests(ITestOutputHelper output) +{ + // Timeout set to 30 minutes for Azure resource provisioning. + private static readonly TimeSpan s_testTimeout = TimeSpan.FromMinutes(30); + + [Fact] + public async Task ResourceCommandReturnsLiveAzureResourceInfo() + { + using var cts = new CancellationTokenSource(s_testTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, TestContext.Current.CancellationToken); + var cancellationToken = linkedCts.Token; + + await ResourceCommandReturnsLiveAzureResourceInfoCore(cancellationToken); + } + + private async Task ResourceCommandReturnsLiveAzureResourceInfoCore(CancellationToken cancellationToken) + { + var subscriptionId = AzureAuthenticationHelpers.TryGetSubscriptionId(); + if (string.IsNullOrEmpty(subscriptionId)) + { + Assert.Skip("Azure subscription not configured. Set ASPIRE_DEPLOYMENT_TEST_SUBSCRIPTION."); + } + + if (!AzureAuthenticationHelpers.IsAzureAuthAvailable()) + { + if (DeploymentE2ETestHelpers.IsRunningInCI) + { + Assert.Fail("Azure authentication not available in CI. Check OIDC configuration."); + } + else + { + Assert.Skip("Azure authentication not available. Run 'az login' to authenticate."); + } + } + + using var workspace = TemporaryWorkspace.Create(output); + var startTime = DateTime.UtcNow; + var resourceGroupName = DeploymentE2ETestHelpers.GenerateResourceGroupName("storage-run"); + var tenantId = AzureAuthenticationHelpers.GetTenantId(); + + output.WriteLine($"Test: {nameof(ResourceCommandReturnsLiveAzureResourceInfo)}"); + output.WriteLine($"Resource Group: {resourceGroupName}"); + output.WriteLine($"Subscription: {subscriptionId[..8]}..."); + output.WriteLine($"Workspace: {workspace.WorkspaceRoot.FullName}"); + + using var terminal = DeploymentE2ETestHelpers.CreateTestTerminal(); + var pendingRun = terminal.RunAsync(cancellationToken); + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + var appHostStarted = false; + var cleanupCommandSucceeded = false; + + try + { + output.WriteLine("Step 1: Preparing environment..."); + await auto.PrepareEnvironmentAsync(workspace, counter); + + await auto.InstallCurrentBuildAspireCliAsync(counter, output); + + output.WriteLine("Step 3: Creating single-file AppHost with aspire init..."); + await auto.AspireInitAsync(counter); + + output.WriteLine("Step 4: Adding Azure Storage hosting package..."); + await auto.TypeAsync("aspire add Aspire.Hosting.Azure.Storage"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter); + + output.WriteLine("Step 5: Modifying apphost.cs to add Azure Storage resource..."); + var appHostFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"); + var appHostContent = File.ReadAllText(appHostFilePath); + appHostContent = appHostContent.Replace( + "builder.Build().Run();", + """ + // This test verifies resource command metadata for a standalone storage account. + // No app consumes the account, so skip default RBAC to avoid testing role assignment propagation. + builder.AddAzureStorage("storage") + .ClearDefaultRoleAssignments(); + + builder.Build().Run(); + """); + File.WriteAllText(appHostFilePath, appHostContent); + + var validateScriptPath = Path.Combine(workspace.WorkspaceRoot.FullName, "validate-get-resource.py"); + File.WriteAllText(validateScriptPath, """ + import json + import sys + from pathlib import Path + + text = Path(sys.argv[1]).read_text(encoding="utf-8") + payload = None + + # The CLI writes command result JSON to stdout, but local runs can include + # launch/build preamble text before the payload: + # Building... + # { "success": true, "command": "get-azure-resource", ... } + # Parse from the first JSON object so the check works in both CI and local runs. + json_start = text.find("{") + if json_start >= 0: + payload = json.JSONDecoder().raw_decode(text[json_start:])[0] + + if payload is None: + raise AssertionError("get-azure-resource did not emit a JSON payload") + + assert payload["success"] is True, payload + assert payload["command"] == "get-azure-resource", payload + assert payload["resourceName"] == "storage", payload + + deployment = payload["deployment"] + live = payload["live"] + assert deployment["hasState"] is True, payload + assert live["checked"] is True, payload + assert live["exists"] is True, payload + + resource_id = deployment["resourceId"] + deployment_id = deployment["deploymentId"] + assert resource_id, payload + assert deployment_id, payload + + Path(sys.argv[2]).write_text(resource_id, encoding="utf-8") + Path(sys.argv[3]).write_text(deployment_id, encoding="utf-8") + """); + + output.WriteLine("Step 6: Setting Azure run-mode context..."); + // When Azure:ResourceGroup is supplied explicitly, run mode treats it as an existing + // group unless Azure:AllowResourceGroupCreation is enabled. This test owns a unique + // group name, so allow provisioning to create it instead of waiting on a non-existent group. + var contextCommand = $"unset ASPIRE_PLAYGROUND && export AZURE__SUBSCRIPTIONID={subscriptionId} && export AZURE__LOCATION=westus3 && export AZURE__RESOURCEGROUP={resourceGroupName} && export AZURE__ALLOWRESOURCEGROUPCREATION=true"; + if (!string.IsNullOrEmpty(tenantId)) + { + contextCommand += $" && export AZURE__TENANTID={tenantId}"; + } + await auto.RunCommandAsync(contextCommand, counter); + + output.WriteLine("Step 7: Starting AppHost with live Azure provisioning..."); + await auto.RunCommandAsync("aspire start --non-interactive --format Json", counter, TimeSpan.FromMinutes(20)); + appHostStarted = true; + + output.WriteLine("Step 8: Waiting for Azure Storage resource to be running..."); + // `aspire start` returns after the AppHost is detached. Run-mode Azure provisioning + // continues inside that AppHost, so wait for the resource state before invoking commands. + await auto.RunCommandAsync("aspire wait storage --status up --timeout 1500 --non-interactive", counter, TimeSpan.FromMinutes(26)); + + output.WriteLine("Step 9: Running get-azure-resource command..."); + await auto.RunCommandAsync("aspire resource storage get-azure-resource --non-interactive > get-resource.json", counter, TimeSpan.FromMinutes(2)); + await auto.RunCommandAsync("python3 validate-get-resource.py get-resource.json storage-resource-id.txt storage-deployment-id.txt", counter, TimeSpan.FromSeconds(30)); + + output.WriteLine("Step 10: Verifying emitted Azure resource IDs with az..."); + await auto.RunCommandAsync("az resource show --ids \"$(cat storage-resource-id.txt)\" --query \"properties.provisioningState\" -o tsv | grep '^Succeeded$'", counter, TimeSpan.FromMinutes(1)); + await auto.RunCommandAsync("az resource show --ids \"$(cat storage-deployment-id.txt)\" --query \"properties.provisioningState\" -o tsv | grep '^Succeeded$'", counter, TimeSpan.FromMinutes(1)); + + output.WriteLine("Step 11: Deleting live Azure resources through the visible Azure control resource..."); + await auto.RunCommandAsync("aspire resource azure-environment delete-azure-resources --non-interactive", counter, TimeSpan.FromMinutes(10)); + cleanupCommandSucceeded = true; + await auto.RunCommandAsync($"az group exists --name {resourceGroupName} -o tsv | grep '^false$'", counter, TimeSpan.FromSeconds(30)); + + var duration = DateTime.UtcNow - startTime; + output.WriteLine($"Run-mode Azure resource command test completed in {duration}"); + + DeploymentReporter.ReportDeploymentSuccess( + nameof(ResourceCommandReturnsLiveAzureResourceInfo), + resourceGroupName, + new Dictionary(), + duration); + } + catch (Exception ex) + { + output.WriteLine($"Test failed: {ex.Message}"); + + DeploymentReporter.ReportDeploymentFailure( + nameof(ResourceCommandReturnsLiveAzureResourceInfo), + resourceGroupName, + ex.Message); + + throw; + } + finally + { + if (appHostStarted) + { + try + { + output.WriteLine("Stopping AppHost..."); + await auto.RunCommandAsync("aspire stop --non-interactive 2>/dev/null || true", counter, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) + { + output.WriteLine($"Failed to stop AppHost: {ex.Message}"); + } + } + + try + { + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + } + catch (Exception ex) + { + output.WriteLine($"Failed to exit terminal cleanly: {ex.Message}"); + } + + if (!cleanupCommandSucceeded) + { + output.WriteLine($"Cleaning up resource group: {resourceGroupName}"); + await CleanupResourceGroupAsync(resourceGroupName); + } + } + } + + private async Task CleanupResourceGroupAsync(string resourceGroupName) + { + try + { + using var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "az", + Arguments = $"group delete --name {resourceGroupName} --yes --no-wait", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + } + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0) + { + output.WriteLine($"Resource group deletion initiated: {resourceGroupName}"); + } + else + { + var error = await process.StandardError.ReadToEndAsync(); + output.WriteLine($"Resource group deletion may have failed (exit code {process.ExitCode}): {error}"); + } + } + catch (Exception ex) + { + output.WriteLine($"Failed to cleanup resource group: {ex.Message}"); + } + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AcrLoginServiceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AcrLoginServiceTests.cs new file mode 100644 index 00000000000..583df6cf104 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AcrLoginServiceTests.cs @@ -0,0 +1,216 @@ +// 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 ASPIRECONTAINERRUNTIME001 + +using System.Net; +using Aspire.Hosting.Tests.Publishing; +using Azure.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Azure.Tests; + +public class AcrLoginServiceTests +{ + [Fact] + public async Task LoginAsync_RetriesTransientExchangeFailures() + { + var handler = new CallbackHttpMessageHandler((attempt, _) => + { + if (attempt < 3) + { + throw new HttpRequestException("Name or service not known"); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"refresh_token":"refresh-token"}""") + }); + }); + var runtime = new FakeContainerRuntime(); + var timeProvider = new ImmediateTimeProvider(); + var service = new AcrLoginService( + new TestHttpClientFactory(handler), + runtime, + NullLogger.Instance, + timeProvider); + + await service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential()); + + Assert.Equal(3, handler.CallCount); + Assert.Equal(2, timeProvider.DelayCount); + Assert.True(runtime.WasLoginToRegistryCalled); + var login = Assert.Single(runtime.LoginToRegistryCalls); + Assert.Equal("registry.azurecr.io", login.registryServer); + Assert.Equal("refresh-token", login.password); + } + + [Fact] + public async Task LoginAsync_DoesNotRetryNonRetryableExchangeFailures() + { + var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad request") + })); + var runtime = new FakeContainerRuntime(); + var timeProvider = new ImmediateTimeProvider(); + var service = new AcrLoginService( + new TestHttpClientFactory(handler), + runtime, + NullLogger.Instance, + timeProvider); + + await Assert.ThrowsAsync(() => service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential())); + + Assert.Equal(1, handler.CallCount); + Assert.Equal(0, timeProvider.DelayCount); + Assert.False(runtime.WasLoginToRegistryCalled); + } + + [Fact] + public async Task LoginAsync_StopsRetryingAfterMaxAttempts() + { + var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("registry not ready") + })); + var runtime = new FakeContainerRuntime(); + var timeProvider = new ImmediateTimeProvider(); + var service = new AcrLoginService( + new TestHttpClientFactory(handler), + runtime, + NullLogger.Instance, + timeProvider); + + await Assert.ThrowsAsync(() => service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential())); + + Assert.Equal(30, handler.CallCount); + Assert.Equal(29, timeProvider.DelayCount); + Assert.False(runtime.WasLoginToRegistryCalled); + } + + [Fact] + public async Task LoginAsync_StopsRetryingAfterTimeBudgetExceeded() + { + var handler = new CallbackHttpMessageHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("registry not ready") + })); + var runtime = new FakeContainerRuntime(); + // ElapsedTimeProvider simulates 2 minutes elapsed after the first GetTimestamp() call, + // so the s_maxLoginRetryDuration (1 minute) guard in ShouldRetryAcrLoginFailure trips + // immediately and the loop stops after a single attempt without entering Task.Delay. + var timeProvider = new ElapsedTimeProvider(); + var service = new AcrLoginService( + new TestHttpClientFactory(handler), + runtime, + NullLogger.Instance, + timeProvider); + + await Assert.ThrowsAsync(() => service.LoginAsync("registry.azurecr.io", "tenant", new StaticTokenCredential())); + + Assert.Equal(1, handler.CallCount); + Assert.Equal(0, timeProvider.DelayCount); + Assert.False(runtime.WasLoginToRegistryCalled); + } + + private sealed class CallbackHttpMessageHandler(Func> callback) : HttpMessageHandler + { + public int CallCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return callback(CallCount, cancellationToken); + } + } + + private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + private sealed class StaticTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("aad-token", DateTimeOffset.MaxValue); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return ValueTask.FromResult(GetToken(requestContext, cancellationToken)); + } + } + + private sealed class ImmediateTimeProvider : TimeProvider + { + public int DelayCount { get; private set; } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + DelayCount++; + var timer = new ImmediateTimer(); + ThreadPool.QueueUserWorkItem(_ => + { + if (!timer.IsDisposed) + { + callback(state); + } + }); + return timer; + } + } + + /// + /// A that reports 2 minutes of elapsed time after the first + /// call so the time-budget guard in + /// ShouldRetryAcrLoginFailure fires immediately after one failed attempt. + /// + private sealed class ElapsedTimeProvider : TimeProvider + { + public int DelayCount { get; private set; } + private int _getTimestampCallCount; + + public override long GetTimestamp() + { + var count = Interlocked.Increment(ref _getTimestampCallCount); + // First call captures the retryStartTimestamp (0). + // All subsequent calls return 2 minutes of ticks so GetElapsedTime() exceeds + // s_maxLoginRetryDuration (1 minute) and the retry guard returns false. + return count == 1 ? 0L : TimestampFrequency * 120; + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + DelayCount++; + var timer = new ImmediateTimer(); + ThreadPool.QueueUserWorkItem(_ => + { + if (!timer.IsDisposed) + { + callback(state); + } + }); + return timer; + } + } + + private sealed class ImmediateTimer : ITimer + { + public bool IsDisposed { get; private set; } + + public bool Change(TimeSpan dueTime, TimeSpan period) => true; + + public void Dispose() + { + IsDisposed = true; + } + + public ValueTask DisposeAsync() + { + IsDisposed = true; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs index 775e647a5f8..8f615b49101 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepProvisionerTests.cs @@ -10,8 +10,13 @@ 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.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -139,6 +144,492 @@ 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 GetOrCreateResourceAsync_PublishesSubscriptionScopedPredictedDeploymentIdAndUrlWhileWaiting() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var resource = new AzureBicepResource("subscriptionDeployment", templateString: "output name string = 'subscriptionDeployment'") + { + Scope = new("test-rg", "test-subscription") + }; + + 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(subscription: new WaitingThrowingSubscriptionResource()); + + await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + var notifications = services.GetRequiredService(); + Assert.True(notifications.TryGetCurrentState(resource.Name, out var resourceEvent)); + var expectedDeploymentId = "/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.Resources/deployments/subscriptionDeployment"; + Assert.Equal("87654321-4321-4321-4321-210987654321", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.tenant.id").Value?.ToString()); + Assert.Equal(expectedDeploymentId, resourceEvent.Snapshot.Properties.Single(p => p.Name == CustomResourceKnownProperties.Source).Value?.ToString()); + Assert.Equal(BicepProvisioner.GetDeploymentUrl(new ResourceIdentifier(expectedDeploymentId)), resourceEvent.Snapshot.Urls.Single(u => u.Name == "deployment").Url); + } + + [Fact] + public async Task ConfigureResourceAsync_DoesNotReuseOverrideOnlyDeploymentState() + { + 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 reused = await provisioner.ConfigureResourceAsync(resource, CancellationToken.None); + + Assert.False(reused); + Assert.Empty(resource.Outputs); + } + + [Fact] + public async Task ConfigureResourceAsync_DoesNotReuseInProgressDeploymentState() + { + 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 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; + section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateRunning; + 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(resource, CancellationToken.None); + + Assert.False(reused); + Assert.Empty(resource.Outputs); + } + + [Fact] + public async Task ConfigureResourceAsync_PublishesAzureIdentityPropertiesFromDeploymentState() + { + 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 parameters = new JsonObject(); + var checksum = BicepUtilities.GetChecksum(resource, parameters, scope: null); + + var deploymentStateManager = services.GetRequiredService(); + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure", CancellationToken.None); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["ResourceGroup"] = "test-rg"; + azureSection.Data["TenantId"] = "87654321-4321-4321-4321-210987654321"; + azureSection.Data["Tenant"] = "microsoft.onmicrosoft.com"; + azureSection.Data["Location"] = "westus2"; + await deploymentStateManager.SaveSectionAsync(azureSection, CancellationToken.None); + + 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(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 ConfigureResourceAsync_PublishesResourceGroupFromCachedDeploymentId() + { + 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 parameters = new JsonObject(); + var checksum = BicepUtilities.GetChecksum(resource, parameters, scope: null); + + var deploymentStateManager = services.GetRequiredService(); + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure", CancellationToken.None); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["ResourceGroup"] = "environment-rg"; + azureSection.Data["TenantId"] = "87654321-4321-4321-4321-210987654321"; + azureSection.Data["Tenant"] = "microsoft.onmicrosoft.com"; + azureSection.Data["Location"] = "westus2"; + await deploymentStateManager.SaveSectionAsync(azureSection, CancellationToken.None); + + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + section.Data["Id"] = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/scoped-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(resource, CancellationToken.None); + + Assert.True(reused); + + var notifications = services.GetRequiredService(); + Assert.True(notifications.TryGetCurrentState(resource.Name, out var resourceEvent)); + Assert.Equal("scoped-rg", resourceEvent.Snapshot.Properties.Single(p => p.Name == "azure.resource.group").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()); + Assert.False(section.Data.ContainsKey(BicepProvisioner.DeploymentStateProvisioningStateKey)); + } + + [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 GetOrCreateResourceAsync_SavesInProgressDeploymentStateBeforeWaiting() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var resourceGroup = new DeploymentCollectionResourceGroupResource(new WaitingThrowingArmDeploymentCollection()); + + 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(resourceGroup: resourceGroup); + + await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.Equal("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/storage2", section.Data["Id"]?.GetValue()); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateRunning, section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + Assert.True(section.Data.ContainsKey("Parameters")); + Assert.True(section.Data.ContainsKey("CheckSum")); + Assert.False(section.Data.ContainsKey("Outputs")); + } + + [Fact] + public async Task GetOrCreateResourceAsync_CancelsStartedDeploymentWhenWaitIsCanceled() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var deploymentCollection = new CancelingArmDeploymentCollection(); + var resourceGroup = new DeploymentCollectionResourceGroupResource(deploymentCollection); + + 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(resourceGroup: resourceGroup); + + await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + Assert.Equal(1, deploymentCollection.CancelCallCount); + Assert.Equal("storage2", deploymentCollection.CanceledDeploymentName); + + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateCanceled, section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + } + + [Fact] + public async Task GetOrCreateResourceAsync_CancelsPendingDeploymentWhenStartIsCanceled() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var deploymentCollection = new PendingCancelArmDeploymentCollection(); + var resourceGroup = new DeploymentCollectionResourceGroupResource(deploymentCollection); + + 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(resourceGroup: resourceGroup); + + await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + Assert.Equal(1, deploymentCollection.CancelCallCount); + Assert.Equal("storage2", deploymentCollection.CanceledDeploymentName); + + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateCanceled, section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + } + + [Fact] + public async Task GetOrCreateResourceAsync_PersistsCanceledStateWhenCancelFindsAlreadyInactiveDeployment() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var deploymentCollection = new AlreadyCanceledArmDeploymentCollection(); + var resourceGroup = new DeploymentCollectionResourceGroupResource(deploymentCollection); + + 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(resourceGroup: resourceGroup); + + await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + Assert.Equal(1, deploymentCollection.CancelCallCount); + + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateCanceled, section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + } + + [Fact] + public async Task GetOrCreateResourceAsync_SavesTerminalDeploymentStateWhenDeploymentFails() + { + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateUserSecretsManager()); + using var services = builder.Services.BuildServiceProvider(); + + var deploymentStateManager = services.GetRequiredService(); + var resource = new AzureBicepResource("storage2", templateString: "output name string = 'storage2'"); + var resourceGroup = new DeploymentCollectionResourceGroupResource(new FailingArmDeploymentCollection()); + + 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(resourceGroup: resourceGroup); + + var exception = await Assert.ThrowsAsync(() => provisioner.GetOrCreateResourceAsync(resource, context, CancellationToken.None)); + + Assert.Contains(ResourcesProvisioningState.Failed.ToString(), exception.Message); + + var section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2", CancellationToken.None); + Assert.Equal(ResourcesProvisioningState.Failed.ToString(), section.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + } + [Fact] public async Task BicepCliExecutor_CompilesBicepToArm() { @@ -272,4 +763,211 @@ public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken c public Task ClearAllStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; } + + private sealed class DeploymentCollectionResourceGroupResource(IArmDeploymentCollection armDeploymentCollection) : IResourceGroupResource + { + public ResourceIdentifier Id { get; } = new("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg"); + public string Name => "test-rg"; + + public IArmDeploymentCollection GetArmDeployments() => armDeploymentCollection; + + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) => + Task.FromResult(new TestDeleteArmOperation()); + + public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + } + + 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()); + } + + public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class WaitingThrowingSubscriptionResource : ISubscriptionResource + { + public ResourceIdentifier Id { get; } = new("/subscriptions/12345678-1234-1234-1234-123456789012"); + + public string? DisplayName => "Test Subscription"; + + public Guid? TenantId => Guid.Parse("87654321-4321-4321-4321-210987654321"); + + public IResourceGroupCollection GetResourceGroups() => new TestResourceGroupCollection(); + + public IArmDeploymentCollection GetArmDeployments() => new WaitingThrowingArmDeploymentCollection(); + } + + private sealed class WaitingThrowingArmDeploymentCollection : IArmDeploymentCollection + { + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + Task.FromResult>(new WaitingThrowingArmDeploymentOperation()); + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class WaitingThrowingArmDeploymentOperation : ArmOperation + { + public override string Id { get; } = Guid.NewGuid().ToString(); + + public override ArmDeploymentResource Value => throw new InvalidOperationException("The deployment did not complete successfully."); + + public override bool HasCompleted => false; + + public override bool HasValue => false; + + 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> WaitForCompletionAsync(CancellationToken cancellationToken = default) => + ValueTask.FromException>(new RequestFailedException(409, "Deployment wait failed.")); + + public override ValueTask> WaitForCompletionAsync(TimeSpan pollingInterval, CancellationToken cancellationToken = default) => + ValueTask.FromException>(new RequestFailedException(409, "Deployment wait failed.")); + + public override Response WaitForCompletion(CancellationToken cancellationToken = default) => + throw new RequestFailedException(409, "Deployment wait failed."); + + public override Response WaitForCompletion(TimeSpan pollingInterval, CancellationToken cancellationToken = default) => + throw new RequestFailedException(409, "Deployment wait failed."); + } + + private sealed class ThrowingArmDeploymentCollection : IArmDeploymentCollection + { + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + throw new RequestFailedException(409, "Deployment creation failed."); + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class CancelingArmDeploymentCollection : IArmDeploymentCollection + { + public int CancelCallCount { get; private set; } + public string? CanceledDeploymentName { get; private set; } + + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + Task.FromResult>(new CancelingArmDeploymentOperation()); + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) + { + CancelCallCount++; + CanceledDeploymentName = deploymentName; + return Task.CompletedTask; + } + } + + private sealed class CancelingArmDeploymentOperation : ArmOperation + { + public override string Id { get; } = Guid.NewGuid().ToString(); + + public override ArmDeploymentResource Value => throw new InvalidOperationException("The deployment did not complete successfully."); + + public override bool HasCompleted => false; + + public override bool HasValue => false; + + 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> WaitForCompletionAsync(CancellationToken cancellationToken = default) => + ValueTask.FromException>(new OperationCanceledException(cancellationToken)); + + public override ValueTask> WaitForCompletionAsync(TimeSpan pollingInterval, CancellationToken cancellationToken = default) => + ValueTask.FromException>(new OperationCanceledException(cancellationToken)); + + public override Response WaitForCompletion(CancellationToken cancellationToken = default) => + throw new OperationCanceledException(cancellationToken); + + public override Response WaitForCompletion(TimeSpan pollingInterval, CancellationToken cancellationToken = default) => + throw new OperationCanceledException(cancellationToken); + } + + private sealed class PendingCancelArmDeploymentCollection : IArmDeploymentCollection + { + public int CancelCallCount { get; private set; } + public string? CanceledDeploymentName { get; private set; } + + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + Task.FromException>(new OperationCanceledException(cancellationToken)); + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) + { + CancelCallCount++; + CanceledDeploymentName = deploymentName; + return Task.CompletedTask; + } + } + + private sealed class AlreadyCanceledArmDeploymentCollection : IArmDeploymentCollection + { + public int CancelCallCount { get; private set; } + + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + Task.FromResult>(new CancelingArmDeploymentOperation()); + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) + { + CancelCallCount++; + throw new RequestFailedException(409, "The deployment is already canceled."); + } + } + + private sealed class FailingArmDeploymentCollection : IArmDeploymentCollection + { + public Task> CreateOrUpdateAsync( + WaitUntil waitUntil, + string deploymentName, + ArmDeploymentContent content, + CancellationToken cancellationToken = default) => + Task.FromResult>(new TestArmOperation( + new TestArmDeploymentResource(deploymentName, [], ResourcesProvisioningState.Failed))); + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) => Task.CompletedTask; + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index 58c36ea781d..0851666c090 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -20,7 +20,6 @@ using Aspire.Hosting.Tests; using Aspire.Hosting.Tests.Publishing; using Aspire.Hosting.Utils; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -1488,7 +1487,7 @@ public Task AcquireSectionAsync(string sectionName, Canc private sealed class NoOpBicepProvisioner : IBicepProvisioner { - public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) { return Task.FromResult(true); } @@ -1501,7 +1500,7 @@ public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningCo private sealed class FailingBicepProvisioner : IBicepProvisioner { - public Task ConfigureResourceAsync(IConfiguration configuration, AzureBicepResource resource, CancellationToken cancellationToken) + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) { return Task.FromResult(false); } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs index c6622577915..ab8c04985d1 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEnvironmentResourceExtensionsTests.cs @@ -1,10 +1,27 @@ #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 ASPIREPIPELINES001 // 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. // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Reflection; +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 Azure; +using Azure.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; namespace Aspire.Hosting.Azure.Tests; @@ -45,30 +62,3694 @@ public void AddAzureEnvironment_CalledMultipleTimes_ReturnsSameResource() } [Fact] - public void AddAzureEnvironment_InRunMode_AddsHiddenResourceToModel() + public void AddAzureEnvironment_InRunMode_AddsControlResourceWithResetCommand() { var builder = CreateBuilder(isRunMode: true); var resourceBuilder = builder.AddAzureEnvironment(); Assert.NotNull(resourceBuilder); - var resource = Assert.Single(builder.Resources.OfType()); - Assert.True(resource.TryGetLastAnnotation(out var snapshot)); - Assert.True(snapshot.InitialSnapshot.IsHidden); + 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 void AddAzureEnvironment_InRunMode_AddsCommandsInDefinitionOrder() + { + var builder = CreateBuilder(isRunMode: true); + + builder.AddAzureEnvironment(); + + var environmentResource = Assert.Single(builder.Resources.OfType()); + var commands = environmentResource.Annotations.OfType().ToArray(); + + Assert.Collection(commands, + command => + { + Assert.Equal(AzureProvisioningController.ResetProvisioningStateCommandName, command.Name); + Assert.True(command.IsHighlighted); + }, + command => + { + Assert.Equal(AzureProvisioningController.ChangeAzureContextCommandName, command.Name); + Assert.True(command.IsHighlighted); + }, + command => + { + Assert.Equal(AzureProvisioningController.ReprovisionAllCommandName, command.Name); + Assert.False(command.IsHighlighted); + }, + command => + { + Assert.Equal(AzureProvisioningController.DeleteAzureResourcesCommandName, command.Name); + Assert.False(command.IsHighlighted); + }); + } + + [Fact] + public void AddAzureEnvironment_InRunMode_AddsSelectableArgumentsToChangeAzureContextCommand() + { + var builder = CreateBuilder(isRunMode: true); + + builder.AddAzureEnvironment(); + + var environmentResource = Assert.Single(builder.Resources.OfType()); + var changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + + Assert.NotNull(changeContextCommand.ValidateArguments); + Assert.Collection(changeContextCommand.Arguments, + input => + { + Assert.Equal("tenantId", input.Name); + Assert.True(input.Required); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.AllowCustomChoice); + Assert.NotNull(input.DynamicLoading); + Assert.True(input.DynamicLoading.AlwaysLoadOnStart); + }, + input => + { + Assert.Equal("subscriptionId", input.Name); + Assert.True(input.Required); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.AllowCustomChoice); + Assert.True(input.Disabled); + Assert.NotNull(input.DynamicLoading); + Assert.True(input.DynamicLoading.AlwaysLoadOnStart); + Assert.Equal(["tenantId"], input.DynamicLoading.DependsOnInputs); + }, + input => + { + Assert.Equal("resourceGroup", input.Name); + Assert.True(input.Required); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.AllowCustomChoice); + Assert.NotNull(input.DynamicLoading); + Assert.True(input.DynamicLoading.AlwaysLoadOnStart); + Assert.Equal(["subscriptionId"], input.DynamicLoading.DependsOnInputs); + }, + input => + { + Assert.Equal(AzureBicepResource.KnownParameters.Location, input.Name); + Assert.True(input.Required); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.AllowCustomChoice); + Assert.NotNull(input.DynamicLoading); + Assert.True(input.DynamicLoading.AlwaysLoadOnStart); + Assert.Equal(["subscriptionId", "resourceGroup"], input.DynamicLoading.DependsOnInputs); + }); + } + + [Fact] + public async Task ChangeAzureContextCommand_DynamicArgumentsLoadAzureContextOptions() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + + builder.Configuration["Azure:TenantId"] = "87654321-4321-4321-4321-210987654321"; + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:ResourceGroup"] = "rg-test-2"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + var changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + var inputs = CloneInputs(changeContextCommand.Arguments); + + var tenantInput = inputs["tenantId"]; + await LoadInputAsync(app.Services, inputs, tenantInput); + + Assert.Contains(tenantInput.Options!, option => option.Key == "87654321-4321-4321-4321-210987654321"); + Assert.Equal("87654321-4321-4321-4321-210987654321", tenantInput.Value); + + var subscriptionInput = inputs["subscriptionId"]; + Assert.True(subscriptionInput.Disabled); + Assert.True(subscriptionInput.DynamicLoading!.AlwaysLoadOnStart); + + await LoadInputAsync(app.Services, inputs, subscriptionInput); + + Assert.False(subscriptionInput.Disabled); + Assert.Contains(subscriptionInput.Options!, option => option.Key == "12345678-1234-1234-1234-123456789012"); + Assert.Equal("12345678-1234-1234-1234-123456789012", subscriptionInput.Value); + + var resourceGroupInput = inputs["resourceGroup"]; + await LoadInputAsync(app.Services, inputs, resourceGroupInput); + + Assert.False(resourceGroupInput.Disabled); + Assert.Contains(resourceGroupInput.Options!, option => option.Key == "rg-test-2"); + Assert.Equal("rg-test-2", resourceGroupInput.Value); + + var locationInput = inputs[AzureBicepResource.KnownParameters.Location]; + await LoadInputAsync(app.Services, inputs, locationInput); + + Assert.True(locationInput.Disabled); + Assert.Equal("westus", locationInput.Value); + Assert.Equal([KeyValuePair.Create("westus", "westus")], locationInput.Options); + } + + [Fact] + public async Task ChangeAzureContextCommand_TenantChangeReloadsSubscriptionOptionsForSelectedTenant() + { + var builder = CreateBuilder(isRunMode: true); + var armClient = new AzureContextOptionsArmClient(); + + builder.Configuration["Azure:TenantId"] = AzureContextOptionsArmClient.FirstTenantId; + builder.Configuration["Azure:SubscriptionId"] = AzureContextOptionsArmClient.FirstSubscriptionId; + builder.Configuration["Azure:ResourceGroup"] = "rg-first"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(new TestDeploymentStateManager()); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new AzureContextOptionsArmClientProvider(armClient)); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + var changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + var inputs = CloneInputs(changeContextCommand.Arguments); + + var tenantInput = inputs["tenantId"]; + await LoadInputAsync(app.Services, inputs, tenantInput); + + Assert.Contains(tenantInput.Options!, option => option.Key == AzureContextOptionsArmClient.SecondTenantId); + Assert.Equal(AzureContextOptionsArmClient.FirstTenantId, tenantInput.Value); + + tenantInput.Value = AzureContextOptionsArmClient.SecondTenantId; + var subscriptionInput = inputs["subscriptionId"]; + subscriptionInput.Disabled = true; + + await LoadInputAsync(app.Services, inputs, subscriptionInput); + + Assert.False(subscriptionInput.Disabled); + Assert.Equal(AzureContextOptionsArmClient.SecondTenantId, armClient.LastSubscriptionTenantId); + Assert.DoesNotContain(subscriptionInput.Options!, option => option.Key == AzureContextOptionsArmClient.FirstSubscriptionId); + Assert.Contains(subscriptionInput.Options!, option => option.Key == AzureContextOptionsArmClient.SecondSubscriptionId); + + subscriptionInput.Value = AzureContextOptionsArmClient.SecondSubscriptionId; + var resourceGroupInput = inputs["resourceGroup"]; + + await LoadInputAsync(app.Services, inputs, resourceGroupInput); + + Assert.Equal(AzureContextOptionsArmClient.SecondSubscriptionId, armClient.LastResourceGroupSubscriptionId); + Assert.Contains(resourceGroupInput.Options!, option => option.Key == "rg-second"); + } + + [Fact] + public async Task ChangeAzureContextCommand_CustomResourceGroupEnablesLocationChoices() + { + var builder = CreateBuilder(isRunMode: true); + + builder.Configuration["Azure:TenantId"] = "87654321-4321-4321-4321-210987654321"; + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:ResourceGroup"] = "rg-new"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(new TestDeploymentStateManager()); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + var changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + var inputs = CloneInputs(changeContextCommand.Arguments); + var subscriptionInput = inputs["subscriptionId"]; + subscriptionInput.Value = "12345678-1234-1234-1234-123456789012"; + var resourceGroupInput = inputs["resourceGroup"]; + resourceGroupInput.Value = "rg-new"; + var locationInput = inputs[AzureBicepResource.KnownParameters.Location]; + locationInput.Disabled = true; + + await LoadInputAsync(app.Services, inputs, locationInput); + + Assert.False(locationInput.Disabled); + Assert.Contains(locationInput.Options!, option => option.Key == "eastus"); + Assert.Contains(locationInput.Options!, option => option.Key == "westus2"); + Assert.Equal("eastus", locationInput.Value); + } + + [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 + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure provisioning state reset.", result.Message); + + 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 RunModePipelineStep_ProvisionsAzureResourcesAfterPrepareStep() + { + 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' = {}"); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var notifications = app.Services.GetRequiredService(); + var environmentResource = Assert.Single(model.Resources.OfType()); + var (steps, pipelineContext) = await CreateAzureEnvironmentPipelineStepsAsync(environmentResource, model, app.Services); + var prepareResourcesStep = Assert.Single(steps, step => step.Name == AzureEnvironmentResource.PrepareResourcesStepName); + var runModeProvisionStep = Assert.Single(steps, step => step.Name == AzureEnvironmentResource.RunModeProvisionStepName); + + Assert.Contains(AzureEnvironmentResource.PrepareResourcesStepName, runModeProvisionStep.DependsOnSteps); + Assert.Contains(WellKnownPipelineSteps.BeforeStart, runModeProvisionStep.RequiredBySteps); + + await using var reportingStep = await new NullPublishingActivityReporter().CreateStepAsync("test"); + var stepContext = new PipelineStepContext + { + PipelineContext = pipelineContext, + ReportingStep = reportingStep + }; + + await prepareResourcesStep.Action(stepContext); + await runModeProvisionStep.Action(stepContext); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal(KnownResourceStates.Running, storageEvent.Snapshot.State?.Text); + Assert.Equal("https://storage.blob.core.windows.net/", storage.Resource.Outputs["blobEndpoint"]); + } + + [Fact] + public async Task EnsureProvisioned_UsesCachedStateWhenMissingResourceProbeCannotAuthenticate() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var cachedStateProvisioner = new CachedStateTestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + cachedStateProvisioner, + 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 storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Outputs"] = """ + { + // Cached output IDs are used to decide whether provisioning can be skipped. + "id": { + "value": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage", + }, + } + """; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var model = app.Services.GetRequiredService(); + var controller = app.Services.GetRequiredService(); + + await controller.EnsureProvisionedAsync(model); + + Assert.Equal(1, cachedStateProvisioner.ConfigureResourceCallCount); + Assert.Equal(0, cachedStateProvisioner.GetOrCreateResourceCallCount); + Assert.Equal("https://storage.blob.core.windows.net/", storage.Resource.Outputs["blobEndpoint"]); + } + + [Fact] + public async Task EnsureProvisioned_UsesCachedStateWhenMissingResourceProbeFailsTransiently() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var cachedStateProvisioner = new CachedStateTestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "eastus"; + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(new ThrowingResourceProbeArmClientProvider(new RequestFailedException(503, "Service unavailable."))); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(sp => new AzureProvisioningController( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp, + cachedStateProvisioner, + 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 storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Outputs"] = """{"id":{"value":"/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storage"}}"""; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var model = app.Services.GetRequiredService(); + var controller = app.Services.GetRequiredService(); + + await controller.EnsureProvisionedAsync(model); + + Assert.Equal(1, cachedStateProvisioner.ConfigureResourceCallCount); + Assert.Equal(0, cachedStateProvisioner.GetOrCreateResourceCallCount); + 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); + var changeLocationCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + var locationArgument = Assert.Single(changeLocationCommand.Arguments); + Assert.Equal(AzureBicepResource.KnownParameters.Location, locationArgument.Name); + Assert.True(locationArgument.Required); + Assert.Contains(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.GetAzureResourceCommandName); + Assert.Contains(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.CancelDeploymentCommandName); + Assert.Contains(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.DeleteAzureResourceCommandName); + 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.CancelDeploymentCommandName || + c.Name == AzureProvisioningController.DeleteAzureResourceCommandName || + c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + } + + [Fact] + public async Task GetAzureResourceCommand_ReturnsCachedDeploymentStateAndLiveStatus() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string tenantId = "87654321-4321-4321-4321-210987654321"; + const string resourceGroup = "test-rg"; + const string resourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Storage/storageAccounts/storage"; + const string deploymentId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Resources/deployments/storage"; + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider([resourceId])); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + var storage = builder.AddBicepTemplateString("storage", "resource storage '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 azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + azureSection.Data["SubscriptionId"] = subscriptionId; + azureSection.Data["Location"] = "westus2"; + azureSection.Data["ResourceGroup"] = resourceGroup; + azureSection.Data["TenantId"] = tenantId; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = deploymentId; + storageSection.Data["Parameters"] = """ + { + // Cached deployment state can be hand-edited while recovering local state. + "location": { "value": "westus2", }, + } + """; + storageSection.Data["Outputs"] = $$""" + { + "id": { + "type": "String", + "value": "{{resourceId}}", + }, + "blobEndpoint": { + "type": "String", + "value": "https://storage.blob.core.windows.net/", + }, + } + """; + storageSection.Data["Scope"] = $$""" + { + "resourceGroup": "{{resourceGroup}}", + } + """; + storageSection.Data["CheckSum"] = "checksum"; + storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateRunning; + storageSection.Data[AzureProvisioningController.LocationOverrideKey] = "westus3"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var getResourceCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.GetAzureResourceCommandName); + + var result = await getResourceCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource information retrieved.", result.Message); + + var data = AssertCommandJsonData(result); + Assert.Equal(AzureProvisioningController.GetAzureResourceCommandName, data["command"]?.GetValue()); + Assert.Equal("storage", data["resourceName"]?.GetValue()); + Assert.Equal("westus3", data["location"]?.GetValue()); + Assert.NotNull(result.Data); + Assert.True(result.Data!.DisplayImmediately); + + var deployment = Assert.IsType(data["deployment"]); + Assert.True(deployment["hasState"]?.GetValue()); + Assert.Equal(deploymentId, deployment["deploymentId"]?.GetValue()); + Assert.Equal(resourceId, deployment["resourceId"]?.GetValue()); + Assert.Equal("westus3", deployment["locationOverride"]?.GetValue()); + Assert.Equal("checksum", deployment["checksum"]?.GetValue()); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateRunning, deployment["provisioningState"]?.GetValue()); + Assert.Contains("/DeploymentDetailsBlade/", deployment["deploymentPortalUrl"]?.GetValue()); + Assert.Contains("/resource/subscriptions/", deployment["resourcePortalUrl"]?.GetValue()); + + var outputs = Assert.IsType(deployment["outputs"]); + Assert.Equal("https://storage.blob.core.windows.net/", outputs["blobEndpoint"]?["value"]?.GetValue()); + var parameters = Assert.IsType(deployment["parameters"]); + Assert.Equal("westus2", parameters["location"]?["value"]?.GetValue()); + var scope = Assert.IsType(deployment["scope"]); + Assert.Equal(resourceGroup, scope["resourceGroup"]?.GetValue()); + + var live = Assert.IsType(data["live"]); + Assert.True(live["checked"]?.GetValue()); + Assert.True(live["exists"]?.GetValue()); + } + + [Fact] + public async Task GetAzureResourceCommand_ReturnsMissingResourceIdReasonWhenCachedStateHasNoOutputId() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = "test-rg"; + 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 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"] = """{"blobEndpoint":{"value":"https://storage.blob.core.windows.net/"}}"""; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var getResourceCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.GetAzureResourceCommandName); + + var result = await getResourceCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.True(result.Data!.DisplayImmediately); + + var data = AssertCommandJsonData(result); + var deployment = Assert.IsType(data["deployment"]); + Assert.True(deployment["hasState"]?.GetValue()); + Assert.Null(deployment["resourceId"]); + + var live = Assert.IsType(data["live"]); + Assert.False(live["checked"]?.GetValue()); + Assert.Null(live["exists"]); + Assert.Equal("missing-resource-id", live["reason"]?.GetValue()); + } + + [Fact] + public async Task GetAzureResourceCommand_ReturnsStructuredRequestFailureWhenLiveProbeFails() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string resourceId = $"/subscriptions/{subscriptionId}/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage"; + + builder.Configuration["Azure:SubscriptionId"] = subscriptionId; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = "test-rg"; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new ThrowingResourceProbeArmClientProvider(new RequestFailedException(403, "Forbidden", "AuthorizationFailed", null))); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + var storage = builder.AddBicepTemplateString("storage", "resource storage '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 storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Outputs"] = $$""" + { + "id": { + "value": "{{resourceId}}" + } + } + """; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var getResourceCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.GetAzureResourceCommandName); + + var result = await getResourceCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + + var data = AssertCommandJsonData(result); + var live = Assert.IsType(data["live"]); + Assert.True(live["checked"]?.GetValue()); + Assert.Null(live["exists"]); + Assert.Equal("request-failed", live["reason"]?.GetValue()); + Assert.Equal(403, live["status"]?.GetValue()); + Assert.Equal("AuthorizationFailed", live["errorCode"]?.GetValue()); + Assert.Contains("Forbidden", live["message"]?.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task GetAzureResourceCommand_ReturnsCredentialUnavailableReasonWhenLiveProbeCannotAuthenticate() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string resourceId = $"/subscriptions/{subscriptionId}/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage"; + + builder.Configuration["Azure:SubscriptionId"] = subscriptionId; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = "test-rg"; + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(); + 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 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": "{{resourceId}}" + } + } + """; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var getResourceCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.GetAzureResourceCommandName); + + var result = await getResourceCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + + var data = AssertCommandJsonData(result); + var live = Assert.IsType(data["live"]); + Assert.True(live["checked"]?.GetValue()); + Assert.Null(live["exists"]); + Assert.Equal("credential-unavailable", live["reason"]?.GetValue()); + Assert.Contains("Credential unavailable", live["message"]?.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task CancelDeploymentCommand_CancelsCachedDeploymentAndMarksStateCanceled() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var canceledDeploymentIds = new List(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string resourceGroup = "test-rg"; + const string deploymentId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Resources/deployments/storage"; + + builder.Configuration["Azure:SubscriptionId"] = subscriptionId; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = resourceGroup; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider( + existingResourceIds: Array.Empty(), + deletedResourceIds: null, + deploymentTargetResourceIds: null, + canceledDeploymentIds: canceledDeploymentIds)); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + 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["Id"] = deploymentId; + storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateRunning; + await deploymentStateManager.SaveSectionAsync(storageSection); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = new("Waiting for Deployment", KnownResourceStateStyles.Info) }); + + var cancelCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.CancelDeploymentCommandName); + + var result = await cancelCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure deployment cancellation requested.", result.Message); + Assert.Equal([deploymentId], canceledDeploymentIds); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateCanceled, storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal("Canceled", storageEvent.Snapshot.State?.Text); + } + + [Fact] + public async Task CancelDeploymentCommand_IsEnabledDuringActiveDeploymentOperation() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new BlockingTestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = "test-rg"; + 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 notifications = app.Services.GetRequiredService(); + var preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + var controller = app.Services.GetRequiredService(); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var provisioningTask = controller.EnsureProvisionedAsync(model, CancellationToken.None); + + await WaitForSignalBeforeOperationCompletesAsync( + testBicepProvisioner.FirstProvisionStarted.Task, + provisioningTask, + "Provisioning completed before the first resource started provisioning."); + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = new("Creating ARM Deployment", KnownResourceStateStyles.Info) }); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + AssertCommandState(storageEvent.Snapshot, AzureProvisioningController.ChangeResourceLocationCommandName, ResourceCommandState.Disabled); + AssertCommandState(storageEvent.Snapshot, AzureProvisioningController.GetAzureResourceCommandName, ResourceCommandState.Enabled); + AssertCommandState(storageEvent.Snapshot, AzureProvisioningController.CancelDeploymentCommandName, ResourceCommandState.Enabled); + AssertCommandState(storageEvent.Snapshot, AzureProvisioningController.DeleteAzureResourceCommandName, ResourceCommandState.Disabled); + AssertCommandState(storageEvent.Snapshot, AzureProvisioningController.ForgetStateCommandName, ResourceCommandState.Disabled); + AssertCommandState(storageEvent.Snapshot, AzureProvisioningController.ReprovisionResourceCommandName, ResourceCommandState.Disabled); + + testBicepProvisioner.AllowFirstProvisionToComplete.TrySetResult(); + await provisioningTask; + } + + [Fact] + public async Task CancelDeploymentCommand_IsDisabledWhenResourceIsNotWaitingForDeployment() + { + 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 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"] = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/storage"; + storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateSucceeded; + await deploymentStateManager.SaveSectionAsync(storageSection); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + var cancelCommand = Assert.Single(storageEvent.Snapshot.Commands, c => c.Name == AzureProvisioningController.CancelDeploymentCommandName); + Assert.Equal(ResourceCommandState.Disabled, cancelCommand.State); + } + + [Fact] + public async Task CancelDeploymentCommand_DoesNotMarkCompletedDeploymentCanceled() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string resourceGroup = "test-rg"; + const string deploymentId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Resources/deployments/storage"; + + builder.Configuration["Azure:SubscriptionId"] = subscriptionId; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = resourceGroup; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new CancelConflictArmClientProvider()); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + 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["Id"] = deploymentId; + storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateSucceeded; + await deploymentStateManager.SaveSectionAsync(storageSection); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + + var cancelCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.CancelDeploymentCommandName); + + var result = await cancelCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.False(result.Success); + Assert.False(result.Canceled); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal(BicepProvisioner.DeploymentStateProvisioningStateSucceeded, storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey]?.GetValue()); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal(KnownResourceStates.Running, storageEvent.Snapshot.State?.Text); + } + + [Fact] + public async Task DeleteAzureResourceCommand_DeletesCachedOutputAndDeploymentOperationTargets() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var deletedResourceIds = new List(); + var canceledDeploymentIds = new List(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string resourceGroup = "test-rg"; + const string deploymentId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Resources/deployments/storage"; + const string storageResourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Storage/storageAccounts/storage26wmkwq4f4li52"; + const string partialIdentityResourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/storage-identity"; + const string nestedDeploymentResourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Resources/deployments/nested"; + + builder.Configuration["Azure:SubscriptionId"] = subscriptionId; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = resourceGroup; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider( + existingResourceIds: [storageResourceId, partialIdentityResourceId, nestedDeploymentResourceId], + deletedResourceIds: deletedResourceIds, + deploymentTargetResourceIds: [partialIdentityResourceId, nestedDeploymentResourceId], + canceledDeploymentIds: canceledDeploymentIds)); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + 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"] = deploymentId; + storageSection.Data["Outputs"] = new JsonObject + { + ["id"] = new JsonObject + { + ["type"] = "String", + ["value"] = storageResourceId + } + }.ToJsonString(); + storageSection.Data[AzureProvisioningController.LocationOverrideKey] = "westus3"; + storageSection.Data[BicepProvisioner.DeploymentStateProvisioningStateKey] = BicepProvisioner.DeploymentStateProvisioningStateRunning; + 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 = new("Failed to Provision", KnownResourceStateStyles.Error) }); + await notifications.PublishUpdateAsync(storage2.Resource, state => state with { State = KnownResourceStates.Running }); + + var deleteCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.DeleteAzureResourceCommandName); + + var result = await deleteCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resources deleted and provisioning state reset.", result.Message); + Assert.Equal([deploymentId], canceledDeploymentIds); + Assert.Equal(2, deletedResourceIds.Count); + Assert.Contains(storageResourceId, deletedResourceIds); + Assert.Contains(partialIdentityResourceId, deletedResourceIds); + Assert.DoesNotContain(nestedDeploymentResourceId, deletedResourceIds); + + var resultData = AssertCommandJsonData(result); + Assert.Equal(2, resultData["deletedResourceCount"]?.GetValue()); + var resultResourceIds = Assert.IsType(resultData["deletedResourceIds"]); + Assert.Equal(deletedResourceIds, resultResourceIds.Select(static resourceId => resourceId!.GetValue())); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal("westus3", storageSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + Assert.False(storageSection.Data.ContainsKey("Id")); + Assert.False(storageSection.Data.ContainsKey("Outputs")); + + 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.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + Assert.Equal(KnownResourceStates.Running, storage2Event.Snapshot.State?.Text); + } + + [Fact] + public async Task DeleteAzureResourceCommand_UpdatesCommandStatesWhileOperationIsActive() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var armClient = new BlockingDeleteArmClient(); + const string subscriptionId = "12345678-1234-1234-1234-123456789012"; + const string resourceGroup = "test-rg"; + const string resourceId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Storage/storageAccounts/storage"; + + builder.Configuration["Azure:SubscriptionId"] = subscriptionId; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = resourceGroup; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new BlockingDeleteArmClientProvider(armClient)); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + + 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()); + var environmentResource = Assert.Single(model.Resources.OfType()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Outputs"] = $$""" + { + "id": { + "value": "{{resourceId}}" + } + } + """; + 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 notifications.PublishUpdateAsync(storage2.Resource, state => state with { State = KnownResourceStates.Running }); + + var deleteCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.DeleteAzureResourceCommandName); + + var commandTask = deleteCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + await WaitForSignalBeforeOperationCompletesAsync( + armClient.DeleteStarted.Task, + commandTask, + "Delete command completed before the ARM delete operation started."); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Disabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + Assert.Equal("Deleting", storageEvent.Snapshot.State?.Text); + AssertAffectedResourceCommandsDuringOperation(storageEvent.Snapshot); + + Assert.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + AssertUnaffectedResourceCommandsDuringOperation(storage2Event.Snapshot); + + armClient.AllowDeleteToComplete.TrySetResult(); + var result = await commandTask; + + Assert.True(result.Success); + Assert.Equal([resourceId], armClient.DeletedResourceIds); + } + + [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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource provisioning state reset.", result.Message); + + 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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource reprovisioning completed.", result.Message); + 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 ReprovisionCommand_UpdatesCommandStatesWhileOperationIsActive() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new BlockingTestBicepProvisioner(); + 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()); + var environmentResource = Assert.Single(model.Resources.OfType()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + await notifications.PublishUpdateAsync(environmentResource, state => state with { State = KnownResourceStates.Running }); + 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 commandTask = reprovisionCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + await WaitForSignalBeforeOperationCompletesAsync( + testBicepProvisioner.FirstProvisionStarted.Task, + commandTask, + "Reprovision command completed before provisioning started."); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Disabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + AssertAffectedResourceCommandsDuringOperation(storageEvent.Snapshot); + + Assert.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + AssertUnaffectedResourceCommandsDuringOperation(storage2Event.Snapshot); + + testBicepProvisioner.AllowFirstProvisionToComplete.TrySetResult(); + var result = await commandTask; + + Assert.True(result.Success); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out environmentEvent)); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Enabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out storageEvent)); + AssertUnaffectedResourceCommandsDuringOperation(storageEvent.Snapshot); + } + + [Fact] + public async Task ReprovisionCommand_ReenablesCommandStatesWhenOperationFails() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new BlockingThrowingTestBicepProvisioner(); + 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' = {}"); + 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()); + var environmentResource = Assert.Single(model.Resources.OfType()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + await notifications.PublishUpdateAsync(environmentResource, state => state with { State = KnownResourceStates.Running }); + 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 commandTask = reprovisionCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + await WaitForSignalBeforeOperationCompletesAsync( + testBicepProvisioner.FirstProvisionStarted.Task, + commandTask, + "Reprovision command completed before provisioning started."); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Disabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + AssertAffectedResourceCommandsDuringOperation(storageEvent.Snapshot); + + testBicepProvisioner.AllowFirstProvisionToThrow.TrySetResult(); + var result = await commandTask; + + Assert.False(result.Success); + Assert.False(result.Canceled); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out environmentEvent)); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Enabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out storageEvent)); + AssertUnaffectedResourceCommandsDuringOperation(storageEvent.Snapshot); + } + + [Fact] + public async Task QueuedOperation_CancelledDuringInitialCommandStateRefreshCompletesAndReenablesCommands() + { + 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>())); + + using var app = builder.Build(); + + var model = app.Services.GetRequiredService(); + var controller = app.Services.GetRequiredService(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queuedOperation = CreateQueuedOperationForTest(model, "EnsureProvisionedIntent", completion, cts.Token); + + await InvokeProcessQueuedOperationAsync(controller, queuedOperation); + + await Assert.ThrowsAsync(() => completion.Task.WaitAsync(s_testSynchronizationTimeout)); + Assert.Equal(ResourceCommandState.Enabled, controller.GetEnvironmentCommandState()); + } + + [Fact] + public async Task MutatingResourceCommands_ExecuteSequentiallyWhenInvokedConcurrently() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new BlockingTestBicepProvisioner(); + 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 preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var reprovisionStorageCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + var reprovisionStorage2Command = Assert.Single(storage2.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + + var storageTask = reprovisionStorageCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + await WaitForSignalBeforeOperationCompletesAsync( + testBicepProvisioner.FirstProvisionStarted.Task, + storageTask, + "First reprovision command completed before provisioning started."); + + var storage2Task = reprovisionStorage2Command.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage2.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.False(storageTask.IsCompleted); + Assert.False(storage2Task.IsCompleted); + Assert.Equal(["storage"], testBicepProvisioner.ProvisionedResources); + + testBicepProvisioner.AllowFirstProvisionToComplete.TrySetResult(); + + var result = await storageTask; + var result2 = await storage2Task; + + Assert.True(result.Success); + Assert.True(result2.Success); + Assert.Equal(["storage", "storage2"], testBicepProvisioner.ProvisionedResources); + } + + [Fact] + public async Task ChangeLocationCommand_UpdatesCommandStatesWhileOperationIsActive() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new BlockingTestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "eastus"; + 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()); + var environmentResource = Assert.Single(model.Resources.OfType()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + await notifications.PublishUpdateAsync(environmentResource, state => state with { State = KnownResourceStates.Running }); + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + 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 commandTask = changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments(("location", "westus2")) + }); + + await WaitForSignalBeforeOperationCompletesAsync( + testBicepProvisioner.FirstProvisionStarted.Task, + commandTask, + "Change location command completed before provisioning started."); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.All(environmentEvent.Snapshot.Commands, command => Assert.Equal(ResourceCommandState.Disabled, command.State)); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var storageEvent)); + AssertAffectedResourceCommandsDuringOperation(storageEvent.Snapshot); + + Assert.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + AssertUnaffectedResourceCommandsDuringOperation(storage2Event.Snapshot); + + testBicepProvisioner.AllowFirstProvisionToComplete.TrySetResult(); + var result = await commandTask; + + Assert.True(result.Success); + } + + [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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + 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.Message); + 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_WithArguments_DoesNotPromptAndReturnsJsonResult() + { + 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.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()); + 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); + 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 result = await changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments((AzureBicepResource.KnownParameters.Location, "West US 3")) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource location updated and reprovisioning completed.", result.Message); + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage"]); + Assert.False(testInteractionService.Interactions.Reader.TryRead(out _)); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal("westus3", storageSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + + var data = AssertCommandJsonData(result); + Assert.Equal(1, data["schemaVersion"]?.GetValue()); + Assert.Equal(AzureProvisioningController.ChangeResourceLocationCommandName, data["command"]?.GetValue()); + Assert.Equal("storage", data["resourceName"]?.GetValue()); + Assert.Equal("westus3", data["location"]?.GetValue()); + Assert.Equal("eastus", data["azureLocation"]?.GetValue()); + } + + [Fact] + public async Task ChangeLocationCommand_ForAnnotatedResource_PersistsOverrideUnderBicepResourceName() + { + 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.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()); + 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 visibleResource = new AnnotatedAzureResource("storage"); + var bicepResource = new AzureBicepResource("storage-deployment", templateString: "resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = {}"); + visibleResource.Annotations.Add(new AzureBicepResourceAnnotation(bicepResource)); + builder.AddResource(visibleResource); + + 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(visibleResource, state => state with { State = KnownResourceStates.Running }); + + var changeLocationCommand = Assert.Single(visibleResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeResourceLocationCommandName); + + var result = await changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = visibleResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments((AzureBicepResource.KnownParameters.Location, "West US 3")) + }); + + Assert.True(result.Success); + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage-deployment"]); + + var bicepSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage-deployment"); + Assert.Equal("westus3", bicepSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + + var visibleSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.False(visibleSection.Data.ContainsKey(AzureProvisioningController.LocationOverrideKey)); + } + + [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); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data[AzureProvisioningController.LocationOverrideKey] = "westus3"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + 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 commandInputs = CloneInputs(changeLocationCommand.Arguments); + var commandLocationInput = commandInputs[AzureBicepResource.KnownParameters.Location]; + + await LoadInputAsync(app.Services, commandInputs, commandLocationInput); + + Assert.Equal("westus3", commandLocationInput.Value); + + var executionTask = changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + 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"); + Assert.Equal("westus3", locationInput.Value); + + 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.Message); + } + + [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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + 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 ChangeLocationCommand_DeletesCachedResourceUsingPersistedLocationWhenSnapshotLocationIsMissing() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + 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(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["Parameters"] = """ + { + // The cached deployment location is used when the resource snapshot has not been published yet. + "location": { + "value": "westus2", + }, + } + """; + storageSection.Data["Outputs"] = $$""" + { + "id": { + "value": "{{resourceId}}", + }, + } + """; + await deploymentStateManager.SaveSectionAsync(storageSection); + + 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 result = await changeLocationCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments((AzureBicepResource.KnownParameters.Location, "westus3")) + }); + + Assert.True(result.Success); + Assert.Equal([resourceId], deletedResourceIds); + Assert.Equal("westus3", testBicepProvisioner.ProvisionedLocations["storage"]); + } + + [Fact] + public async Task ChangeLocationCommand_UsesRequestedLocationWhenChangingExistingOverride() + { + 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"] = "eastus"; + 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["LocationOverride"] = "westus2"; + 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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + 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"]); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal("westus3", storageSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [Fact] + public async Task ChangeLocationCommand_TreatsDeletedCachedResourceAsAlreadyAbsent() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var testInteractionService = new TestInteractionService(); + 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(new DeleteResourceFailureArmClientProvider(resourceId, new RequestFailedException(404, "Not found."))); + 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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + 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("westus3", testBicepProvisioner.ProvisionedLocations["storage"]); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal("westus3", storageSection.Data[AzureProvisioningController.LocationOverrideKey]?.GetValue()); + } + + [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 + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure reprovisioning completed.", result.Message); + + 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 ChangeAzureContextCommand_WithArguments_PersistsContextAndReturnsJsonResult() + { + 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 environmentResource = Assert.Single(model.Resources.OfType()); + + var changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + + var result = await changeContextCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments( + ("subscriptionId", "12345678-1234-1234-1234-123456789012"), + ("resourceGroup", "cli-rg-é"), + (AzureBicepResource.KnownParameters.Location, "West US 3"), + ("tenantId", "87654321-4321-4321-4321-210987654321")) + }); + + Assert.True(result.Success); + Assert.Equal("Azure context updated and resources reprovisioned.", result.Message); + Assert.Contains("storage", testBicepProvisioner.ProvisionedResources); + Assert.False(testInteractionService.Interactions.Reader.TryRead(out _)); + + var azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + Assert.Equal("12345678-1234-1234-1234-123456789012", azureSection.Data["SubscriptionId"]?.GetValue()); + Assert.Equal("westus3", azureSection.Data["Location"]?.GetValue()); + Assert.Equal("cli-rg-é", azureSection.Data["ResourceGroup"]?.GetValue()); + Assert.Equal("87654321-4321-4321-4321-210987654321", azureSection.Data["TenantId"]?.GetValue()); + + var data = AssertCommandJsonData(result); + Assert.Equal(1, data["schemaVersion"]?.GetValue()); + Assert.Equal(AzureProvisioningController.ChangeAzureContextCommandName, data["command"]?.GetValue()); + Assert.Equal("12345678-1234-1234-1234-123456789012", data["subscriptionId"]?.GetValue()); + Assert.Equal("87654321-4321-4321-4321-210987654321", data["tenantId"]?.GetValue()); + Assert.Equal("cli-rg-é", data["resourceGroup"]?.GetValue()); + Assert.Equal("westus3", data["azureLocation"]?.GetValue()); + Assert.Contains("cli-rg-é", result.Data!.Value, StringComparison.Ordinal); + Assert.DoesNotContain("\\u00E9", result.Data.Value, StringComparison.OrdinalIgnoreCase); + + Assert.Equal(1, data["resourceCount"]?.GetValue()); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.Equal(KnownResourceStates.Running, environmentEvent.Snapshot.State?.Text); + Assert.Equal("12345678-1234-1234-1234-123456789012", environmentEvent.Snapshot.Properties.Single(p => p.Name == "azure.subscription.id").Value?.ToString()); + Assert.Equal("cli-rg-é", environmentEvent.Snapshot.Properties.Single(p => p.Name == "azure.resource.group").Value?.ToString()); + Assert.Equal("westus3", environmentEvent.Snapshot.Properties.Single(p => p.Name == "azure.location").Value?.ToString()); + Assert.Equal("87654321-4321-4321-4321-210987654321", environmentEvent.Snapshot.Properties.Single(p => p.Name == "azure.tenant.id").Value?.ToString()); + } + + [Fact] + public async Task ChangeAzureContextCommand_WithArgumentsWithoutTenant_ClearsPersistedTenant() + { + 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.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>())); + + 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"] = "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 changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + + var result = await changeContextCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments( + ("subscriptionId", "12345678-1234-1234-1234-123456789012"), + ("resourceGroup", "cli-rg"), + (AzureBicepResource.KnownParameters.Location, "West US 3")) + }); + + Assert.True(result.Success, result.Message); + + azureSection = await deploymentStateManager.AcquireSectionAsync("Azure"); + Assert.False(azureSection.Data.ContainsKey("TenantId")); + + var data = AssertCommandJsonData(result); + Assert.Null(data["tenantId"]); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.DoesNotContain(environmentEvent.Snapshot.Properties, p => p.Name == "azure.tenant.id"); + } + + [Fact] + public async Task ChangeAzureContextCommand_DoesNotInferResourceLocationOverrides() + { + 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.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 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"; + await deploymentStateManager.SaveSectionAsync(azureSection); + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Parameters"] = """{"location":{"value":"westus2"}}"""; + await deploymentStateManager.SaveSectionAsync(storageSection); + + await notifications.PublishUpdateAsync(storage.Resource, state => state with + { + State = KnownResourceStates.Running, + Properties = [new("azure.location", "westus2")] + }); + + var changeContextCommand = Assert.Single(environmentResource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ChangeAzureContextCommandName); + + var result = await changeContextCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = CreateArguments( + ("subscriptionId", "12345678-1234-1234-1234-123456789012"), + ("resourceGroup", "cli-rg"), + (AzureBicepResource.KnownParameters.Location, "West US 3")) + }); + + Assert.True(result.Success, result.Message); + Assert.NotEqual("westus2", testBicepProvisioner.ProvisionedLocations["storage"]); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.False(storageSection.Data.ContainsKey(AzureProvisioningController.LocationOverrideKey)); + } + + [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"] = """ + { + // Preserve resource-specific locations from cached deployment 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 + { + Services = app.Services, + ResourceName = storage2.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resource reprovisioning completed.", result.Message); + + 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 ForgetResourceStateCommand_ClearsInMemoryLocationParameter() + { + 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' = {}"); + + 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 storage = model.Resources.OfType().Single(r => r.Name == "storage"); + storage.Parameters[AzureBicepResource.KnownParameters.Location] = "westus3"; + + var storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + storageSection.Data["Id"] = "storage-deployment"; + storageSection.Data[AzureProvisioningController.LocationOverrideKey] = "westus3"; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var controller = app.Services.GetRequiredService(); + + await controller.ForgetResourceStateAsync(model, "storage"); + + Assert.False(storage.Parameters.ContainsKey(AzureBicepResource.KnownParameters.Location)); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + } + + [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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.False(result.Success); + Assert.False(result.Canceled); + } + + [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 + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + 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); + Assert.False(result.Canceled); + } + + [Fact] + public async Task ResourceCommandCancellation_ReturnsCanceledResult() + { + 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 preparer = new AzureResourcePreparer( + app.Services.GetRequiredService>(), + app.Services.GetRequiredService()); + + await preparer.OnBeforeStartAsync(new BeforeStartEvent(app.Services, model), CancellationToken.None); + + var reprovisionCommand = Assert.Single(storage.Resource.Annotations.OfType(), c => c.Name == AzureProvisioningController.ReprovisionResourceCommandName); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var result = await reprovisionCommand.ExecuteCommand(new ExecuteCommandContext + { + Services = app.Services, + ResourceName = storage.Resource.Name, + CancellationToken = cts.Token, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.False(result.Success); + Assert.True(result.Canceled); + } + + [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.Equal(KnownResourceStateStyles.Error, environmentEvent.Snapshot.State?.Style); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var resourceEvent)); + Assert.Equal(AzureProvisioningController.MissingInAzureState, resourceEvent.Snapshot.State?.Text); + Assert.Equal(KnownResourceStateStyles.Error, resourceEvent.Snapshot.State?.Style); + } + + [Fact] + public async Task CheckForDriftAsync_LeavesRunningResourcesWhenAzureResourcesStillExist() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + const string resourceId = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage"; + + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider([resourceId])); + 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": "{{resourceId}}" + } + } + """; + 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(KnownResourceStates.Running, environmentEvent.Snapshot.State?.Text); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var resourceEvent)); + Assert.Equal(KnownResourceStates.Running, resourceEvent.Snapshot.State?.Text); + } + + [Fact] + public async Task CheckForDriftAsync_MarksOnlyMissingResourceWhenOtherAzureResourcesStillExist() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + const string existingResourceId = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage"; + const string missingResourceId = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Storage/storageAccounts/storage2"; + + builder.Services.AddSingleton(deploymentStateManager); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProvider([existingResourceId])); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateTokenCredentialProvider()); + 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 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": "{{existingResourceId}}" + } + } + """; + await deploymentStateManager.SaveSectionAsync(storageSection); + + var storage2Section = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage2"); + storage2Section.Data["Outputs"] = $$""" + { + "id": { + "value": "{{missingResourceId}}" + } + } + """; + await deploymentStateManager.SaveSectionAsync(storage2Section); + + await notifications.PublishUpdateAsync(environmentResource, state => state with { State = KnownResourceStates.Running }); + await notifications.PublishUpdateAsync(storage.Resource, state => state with { State = KnownResourceStates.Running }); + await notifications.PublishUpdateAsync(storage2.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 storageEvent)); + Assert.Equal(KnownResourceStates.Running, storageEvent.Snapshot.State?.Text); + + Assert.True(notifications.TryGetCurrentState(storage2.Resource.Name, out var storage2Event)); + Assert.Equal(AzureProvisioningController.MissingInAzureState, storage2Event.Snapshot.State?.Text); + Assert.Equal(KnownResourceStateStyles.Error, storage2Event.Snapshot.State?.Style); + } + + [Fact] + public async Task CheckForDriftAsync_SkipsResourcesWithoutCachedResourceIds() + { + 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"] = """{"blobEndpoint":{"value":"https://storage.blob.core.windows.net/"}}"""; + 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(KnownResourceStates.Running, environmentEvent.Snapshot.State?.Text); + + Assert.True(notifications.TryGetCurrentState(storage.Resource.Name, out var resourceEvent)); + Assert.Equal(KnownResourceStates.Running, 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(); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new TestArmClientProvider(resourceGroup)); + 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 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 + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resources deleted and provisioning state reset.", result.Message); + Assert.Equal(1, resourceGroup.DeleteCallCount); + Assert.Equal(0, testProvisioningContextProvider.CreateProvisioningContextCallCount); + + 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 DeleteAzureResourcesCommand_DoesNotDeleteConfiguredResourceGroupWhenPersistedContextIsMissing() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var resourceGroup = new TestResourceGroupResource("configured-rg"); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + + builder.Configuration["Azure:SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + builder.Configuration["Azure:Location"] = "westus2"; + builder.Configuration["Azure:ResourceGroup"] = "configured-rg"; + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new TestArmClientProvider(resourceGroup)); + 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 environmentResource = Assert.Single(model.Resources.OfType()); + + 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 + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resources deleted and provisioning state reset.", result.Message); + Assert.Equal(0, resourceGroup.DeleteCallCount); + Assert.Equal(0, testProvisioningContextProvider.CreateProvisioningContextCallCount); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + Assert.Empty(storage.Resource.Outputs); + } + + [Fact] + public async Task DeleteAzureResourcesCommand_TreatsMissingResourceGroupAsSuccessAndClearsState() + { + 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.RemoveAll(); + builder.Services.AddSingleton(ProvisioningTestHelpers.CreateArmClientProviderForMissingResourceGroup()); + 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 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"] = "missing-rg"; + 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 + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.True(result.Success); + Assert.Equal("Azure resources deleted and provisioning state reset.", result.Message); + Assert.Equal(0, testProvisioningContextProvider.CreateProvisioningContextCallCount); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Empty(storageSection.Data); + Assert.Empty(storage.Resource.Outputs); + } + + [Fact] + public async Task DeleteAzureResourcesCommand_PublishesFailureWhenResourceGroupDeleteFails() + { + var builder = CreateBuilder(isRunMode: true); + var deploymentStateManager = new TestDeploymentStateManager(); + var testBicepProvisioner = new TestBicepProvisioner(); + var testProvisioningContextProvider = new TestProvisioningContextProvider(); + var resourceGroup = new TestResourceGroupResource("test-rg", new RequestFailedException(409, "Resource group is locked.")); + + builder.Services.AddSingleton(deploymentStateManager); + builder.AddAzureProvisioning(); + builder.Services.RemoveAll(); + builder.Services.RemoveAll(); + builder.Services.AddSingleton(new TestArmClientProvider(resourceGroup)); + 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 environmentResource = Assert.Single(model.Resources.OfType()); + var notifications = 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["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 + { + Services = app.Services, + ResourceName = environmentResource.Name, + CancellationToken = CancellationToken.None, + Logger = NullLogger.Instance, + Arguments = new InteractionInputCollection([]) + }); + + Assert.False(result.Success); + Assert.Equal(1, resourceGroup.DeleteCallCount); + Assert.Equal(0, testProvisioningContextProvider.CreateProvisioningContextCallCount); + + Assert.True(notifications.TryGetCurrentState(environmentResource.Name, out var environmentEvent)); + Assert.Equal("Failed to Delete", environmentEvent.Snapshot.State?.Text); + + storageSection = await deploymentStateManager.AcquireSectionAsync("Azure:Deployments:storage"); + Assert.Equal("storage-deployment", storageSection.Data["Id"]?.GetValue()); + } + + [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 WaitForSignalBeforeOperationCompletesAsync( + testBicepProvisioner.FirstProvisionStarted.Task, + reprovisionTask, + "Provisioning completed before the first resource started provisioning."); + Assert.Equal(["storage"], testBicepProvisioner.ProvisionedResources); + + testBicepProvisioner.AllowFirstProvisionToComplete.TrySetResult(); + await reprovisionTask; + + Assert.Equal(["storage", "storage-roles"], testBicepProvisioner.ProvisionedResources); + } + + [Fact] + public async Task EnsureProvisioned_FaultsDependentsWhenDependencyProvisioningFails() + { + 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 = 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 notifications = app.Services.GetRequiredService(); + var controller = app.Services.GetRequiredService(); + + await controller.EnsureProvisionedAsync(model); + + Assert.True(notifications.TryGetCurrentState(storage.Name, out var storageEvent)); + Assert.Equal("Failed to Provision", storageEvent.Snapshot.State?.Text); + + Assert.True(notifications.TryGetCurrentState(storageRoles.Name, out var storageRolesEvent)); + Assert.Equal("Failed to Provision", storageRolesEvent.Snapshot.State?.Text); } [Fact] - public void AddAzureEnvironment_CreatesDefaultName() + public void AddAzureEnvironment_InPublishMode_CreatesStableDeploymentName() { // Arrange var builder = CreateBuilder(isRunMode: false); + builder.Configuration["AppHost:ProjectNameSha256"] = "ABCDE12345"; + + // Act + builder.AddAzureEnvironment(); + + // Assert + var resource = builder.Resources.OfType().Single(); + Assert.Equal("azure-environment", resource.Name); + } + + [Fact] + public void AddAzureEnvironment_InRunMode_CreatesDiscoverableControlResourceName() + { + // Arrange + var builder = CreateBuilder(isRunMode: true); + + // Act + builder.AddAzureEnvironment(); + + // Assert + var resource = builder.Resources.OfType().Single(); + Assert.Equal("azure-environment", resource.Name); + } + + [Fact] + public void AzureEnvironmentResource_PreservesDefaultResourceNameValidation() + { + var builder = CreateBuilder(isRunMode: true); + var location = new ParameterResource("location", _ => "westus"); + var resourceGroupName = new ParameterResource("resourceGroupName", _ => "rg"); + var principalId = new ParameterResource("principalId", _ => "principal"); + var resource = new AzureEnvironmentResource("azure_environment", location, resourceGroupName, principalId); + + var ex = Assert.Throws(() => builder.AddResource(resource)); + Assert.Equal("Resource name 'azure_environment' is invalid. Name must contain only ASCII letters, digits, and hyphens. (Parameter 'name')", ex.Message); + } + + [Fact] + public void AddAzureEnvironment_CreatesFallbackNameWhenAzureResourceNameExists() + { + // Arrange + var builder = CreateBuilder(isRunMode: true); + builder.AddParameter("azure-environment", "value"); // Act builder.AddAzureEnvironment(); // Assert var resource = builder.Resources.OfType().Single(); - Assert.StartsWith("azure", resource.Name); + Assert.Equal("azure-environment2", resource.Name); } [Fact] @@ -108,4 +3789,787 @@ private static IDistributedApplicationBuilder CreateBuilder(bool isRunMode = fal var operation = isRunMode ? DistributedApplicationOperation.Run : DistributedApplicationOperation.Publish; return TestDistributedApplicationBuilder.Create(operation); } + + private static InteractionInputCollection CreateArguments(params (string Name, string? Value)[] values) + { + return new InteractionInputCollection([.. values.Select(static value => new InteractionInput + { + Name = value.Name, + InputType = InputType.Text, + Value = value.Value + })]); + } + + private static InteractionInputCollection CloneInputs(IReadOnlyList inputs) + { + return new InteractionInputCollection([.. inputs.Select(static input => new InteractionInput + { + Name = input.Name, + Label = input.Label, + Description = input.Description, + EnableDescriptionMarkdown = input.EnableDescriptionMarkdown, + InputType = input.InputType, + Required = input.Required, + Options = input.Options, + DynamicLoading = input.DynamicLoading, + Value = input.Value, + Placeholder = input.Placeholder, + AllowCustomChoice = input.AllowCustomChoice, + Disabled = input.Disabled, + MaxLength = input.MaxLength + })]); + } + + private static Task LoadInputAsync(IServiceProvider services, InteractionInputCollection inputs, InteractionInput input) + { + return input.DynamicLoading!.LoadCallback(new LoadInputContext + { + AllInputs = inputs, + CancellationToken = CancellationToken.None, + Input = input, + Services = services + }); + } + + private static object CreateQueuedOperationForTest( + DistributedApplicationModel model, + string intentTypeName, + TaskCompletionSource completion, + CancellationToken cancellationToken) + { + // This targets the private queue boundary directly because the regression window is between + // ProcessOperationLoopAsync's pre-dispatch cancellation check and ProcessQueuedOperationAsync's + // initial command-state refresh. Command-level tests cannot deterministically cancel in that + // narrow synchronous window without relying on timing. + var controllerType = typeof(AzureProvisioningController); + var intentType = controllerType.GetNestedType(intentTypeName, BindingFlags.NonPublic); + Assert.NotNull(intentType); + var intent = Activator.CreateInstance(intentType, nonPublic: true); + Assert.NotNull(intent); + + var queuedOperationType = controllerType.GetNestedType("QueuedOperation", BindingFlags.NonPublic); + Assert.NotNull(queuedOperationType); + var queuedOperation = Activator.CreateInstance( + queuedOperationType, + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + args: [model, intent, completion, cancellationToken], + culture: null); + Assert.NotNull(queuedOperation); + + return queuedOperation; + } + + private static async Task InvokeProcessQueuedOperationAsync(AzureProvisioningController controller, object queuedOperation) + { + var method = typeof(AzureProvisioningController).GetMethod("ProcessQueuedOperationAsync", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var task = Assert.IsAssignableFrom(method.Invoke(controller, [queuedOperation])); + await task.WaitAsync(s_testSynchronizationTimeout).ConfigureAwait(false); + } + + private static JsonObject AssertCommandJsonData(ExecuteCommandResult result) + { + Assert.NotNull(result.Data); + var data = result.Data!; + Assert.Equal(CommandResultFormat.Json, data.Format); + return Assert.IsType(JsonNode.Parse(data.Value)); + } + + private static void AssertAffectedResourceCommandsDuringOperation(CustomResourceSnapshot snapshot) + { + AssertCommandState(snapshot, AzureProvisioningController.ChangeResourceLocationCommandName, ResourceCommandState.Disabled); + AssertCommandState(snapshot, AzureProvisioningController.GetAzureResourceCommandName, ResourceCommandState.Enabled); + AssertCommandState(snapshot, AzureProvisioningController.CancelDeploymentCommandName, ResourceCommandState.Disabled); + AssertCommandState(snapshot, AzureProvisioningController.DeleteAzureResourceCommandName, ResourceCommandState.Disabled); + AssertCommandState(snapshot, AzureProvisioningController.ForgetStateCommandName, ResourceCommandState.Disabled); + AssertCommandState(snapshot, AzureProvisioningController.ReprovisionResourceCommandName, ResourceCommandState.Disabled); + } + + private static void AssertUnaffectedResourceCommandsDuringOperation(CustomResourceSnapshot snapshot) + { + AssertCommandState(snapshot, AzureProvisioningController.ChangeResourceLocationCommandName, ResourceCommandState.Enabled); + AssertCommandState(snapshot, AzureProvisioningController.GetAzureResourceCommandName, ResourceCommandState.Enabled); + AssertCommandState(snapshot, AzureProvisioningController.CancelDeploymentCommandName, ResourceCommandState.Disabled); + AssertCommandState(snapshot, AzureProvisioningController.DeleteAzureResourceCommandName, ResourceCommandState.Enabled); + AssertCommandState(snapshot, AzureProvisioningController.ForgetStateCommandName, ResourceCommandState.Enabled); + AssertCommandState(snapshot, AzureProvisioningController.ReprovisionResourceCommandName, ResourceCommandState.Enabled); + } + + private static void AssertCommandState(CustomResourceSnapshot snapshot, string commandName, ResourceCommandState expectedState) + { + var command = Assert.Single(snapshot.Commands, c => c.Name == commandName); + Assert.Equal(expectedState, command.State); + } + + private static readonly TimeSpan s_testSynchronizationTimeout = TimeSpan.FromSeconds(30); + + private static async Task WaitForSignalBeforeOperationCompletesAsync(Task signalTask, Task operationTask, string completionMessage) + { + using var watchdog = new CancellationTokenSource(s_testSynchronizationTimeout); + Task completedTask; + + try + { + completedTask = await Task.WhenAny(signalTask, operationTask).WaitAsync(watchdog.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (watchdog.IsCancellationRequested) + { + Assert.Fail($"Timed out after {s_testSynchronizationTimeout} waiting for the test synchronization signal. Signal status: {signalTask.Status}. Operation status: {operationTask.Status}. {completionMessage}"); + return; + } + + if (completedTask == signalTask || signalTask.IsCompleted) + { + await signalTask.ConfigureAwait(false); + return; + } + + if (operationTask.IsFaulted || operationTask.IsCanceled) + { + await operationTask.ConfigureAwait(false); + } + + Assert.Fail(completionMessage); + } + + private static async Task<(IReadOnlyList Steps, PipelineContext PipelineContext)> CreateAzureEnvironmentPipelineStepsAsync( + AzureEnvironmentResource environmentResource, + DistributedApplicationModel model, + IServiceProvider services) + { + var pipelineContext = new PipelineContext( + model, + services.GetRequiredService(), + services, + NullLogger.Instance, + CancellationToken.None); + + var annotation = Assert.Single(environmentResource.Annotations.OfType()); + var steps = await annotation.CreateStepsAsync(new PipelineStepFactoryContext + { + PipelineContext = pipelineContext, + Resource = environmentResource + }); + + return ([.. steps], pipelineContext); + } + + private sealed class AzureContextOptionsArmClientProvider(AzureContextOptionsArmClient armClient) : IArmClientProvider + { + public IArmClient GetArmClient(TokenCredential credential, string subscriptionId) + => armClient; + + public IArmClient GetArmClient(TokenCredential credential) + => armClient; + } + + private sealed class AzureContextOptionsArmClient : IArmClient + { + public const string FirstTenantId = "11111111-1111-1111-1111-111111111111"; + public const string SecondTenantId = "22222222-2222-2222-2222-222222222222"; + public const string FirstSubscriptionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + public const string SecondSubscriptionId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + + private readonly IReadOnlyList _tenants = + [ + new(FirstTenantId, "First tenant"), + new(SecondTenantId, "Second tenant") + ]; + private readonly Dictionary> _subscriptionsByTenant = new(StringComparer.OrdinalIgnoreCase) + { + [FirstTenantId] = [new(FirstSubscriptionId, "First subscription", FirstTenantId)], + [SecondTenantId] = [new(SecondSubscriptionId, "Second subscription", SecondTenantId)] + }; + private readonly Dictionary> _resourceGroupsBySubscription = new(StringComparer.OrdinalIgnoreCase) + { + [FirstSubscriptionId] = [("rg-first", "eastus")], + [SecondSubscriptionId] = [("rg-second", "westus2")] + }; + private readonly IReadOnlyList<(string Name, string DisplayName)> _locations = + [ + ("eastus", "East US"), + ("westus2", "West US 2") + ]; + + public string? LastSubscriptionTenantId { get; private set; } + + public string? LastResourceGroupSubscriptionId { get; private set; } + + public Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default) + { + var subscription = _subscriptionsByTenant[FirstTenantId].Single(); + var tenant = _tenants.Single(t => t.TenantId == Guid.Parse(FirstTenantId)); + return Task.FromResult<(ISubscriptionResource, ITenantResource)>((subscription, tenant)); + } + + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + { + IEnumerable result = _tenants; + return Task.FromResult(result); + } + + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) + { + IEnumerable result = _subscriptionsByTenant.Values.SelectMany(static subscriptions => subscriptions); + return Task.FromResult(result); + } + + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + { + LastSubscriptionTenantId = tenantId; + + IEnumerable result = tenantId is not null && _subscriptionsByTenant.TryGetValue(tenantId, out var subscriptions) + ? subscriptions + : []; + return Task.FromResult(result); + } + + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + return Task.FromResult>(_locations); + } + + public Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + LastResourceGroupSubscriptionId = subscriptionId; + + IEnumerable<(string Name, string Location)> result = _resourceGroupsBySubscription.TryGetValue(subscriptionId, out var resourceGroups) + ? resourceGroups + : []; + return Task.FromResult(result); + } + + public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) + => throw new NotSupportedException(); + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class ContextTenantResource(string tenantId, string displayName) : ITenantResource + { + public Guid? TenantId { get; } = Guid.Parse(tenantId); + + public string? DisplayName { get; } = displayName; + + public string? DefaultDomain { get; } = $"{displayName.Replace(" ", "", StringComparison.Ordinal).ToLowerInvariant()}.onmicrosoft.com"; + } + + private sealed class ContextSubscriptionResource(string subscriptionId, string displayName, string tenantId) : ISubscriptionResource + { + public ResourceIdentifier Id { get; } = new($"/subscriptions/{subscriptionId}"); + + public string? DisplayName { get; } = displayName; + + public Guid? TenantId { get; } = Guid.Parse(tenantId); + + public IArmDeploymentCollection GetArmDeployments() + => new TestArmDeploymentCollection([]); + + public IResourceGroupCollection GetResourceGroups() + => new TestResourceGroupCollection([]); + } + + private sealed class AnnotatedAzureResource(string name) : Resource(name); + + private sealed class TestDeploymentStateManager : IDeploymentStateManager + { + private readonly object _lock = new(); + private readonly Dictionary _sections = new(StringComparer.Ordinal); + + public string? StateFilePath => null; + + public Task AcquireSectionAsync(string sectionName, CancellationToken cancellationToken = default) + { + JsonObject data; + lock (_lock) + { + _sections.TryGetValue(sectionName, out var existingData); + data = existingData?.DeepClone().AsObject() ?? []; + } + + return Task.FromResult(new DeploymentStateSection(sectionName, data, version: 0)); + } + + public Task DeleteSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sections.Remove(section.SectionName); + } + + return Task.CompletedTask; + } + + public Task SaveSectionAsync(DeploymentStateSection section, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sections[section.SectionName] = section.Data.DeepClone().AsObject(); + } + + return Task.CompletedTask; + } + + public Task ClearAllStateAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + { + _sections.Clear(); + } + + return Task.CompletedTask; + } + } + + private sealed class TestBicepProvisioner : IBicepProvisioner + { + private readonly object _lock = new(); + private readonly List _configuredResources = []; + private readonly List _provisionedResources = []; + private readonly Dictionary _provisionedLocations = new(StringComparer.Ordinal); + + public int ConfigureResourceCallCount { get; private set; } + + public int GetOrCreateResourceCallCount { get; private set; } + + public IReadOnlyList ConfiguredResources + { + get + { + lock (_lock) + { + return [.. _configuredResources]; + } + } + } + + public IReadOnlyList ProvisionedResources + { + get + { + lock (_lock) + { + return [.. _provisionedResources]; + } + } + } + + public IReadOnlyDictionary ProvisionedLocations + { + get + { + lock (_lock) + { + return new Dictionary(_provisionedLocations, StringComparer.Ordinal); + } + } + } + + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + lock (_lock) + { + ConfigureResourceCallCount++; + _configuredResources.Add(resource.Name); + } + + return Task.FromResult(false); + } + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + lock (_lock) + { + 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 CachedStateTestBicepProvisioner : IBicepProvisioner + { + public int ConfigureResourceCallCount { get; private set; } + + public int GetOrCreateResourceCallCount { get; private set; } + + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) + { + ConfigureResourceCallCount++; + resource.Outputs["blobEndpoint"] = "https://storage.blob.core.windows.net/"; + return Task.FromResult(true); + } + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + GetOrCreateResourceCallCount++; + 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 + { + private readonly object _lock = new(); + private readonly List _provisionedResources = []; + + public TaskCompletionSource FirstProvisionStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowFirstProvisionToComplete { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public IReadOnlyList ProvisionedResources + { + get + { + lock (_lock) + { + return [.. _provisionedResources]; + } + } + } + + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) => Task.FromResult(false); + + public async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + bool isFirstProvision; + lock (_lock) + { + _provisionedResources.Add(resource.Name); + isFirstProvision = _provisionedResources.Count == 1; + } + + if (isFirstProvision) + { + FirstProvisionStarted.TrySetResult(); + await AllowFirstProvisionToComplete.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + resource.Outputs["blobEndpoint"] = $"https://{resource.Name}.blob.core.windows.net/"; + } + } + + private sealed class BlockingThrowingTestBicepProvisioner : IBicepProvisioner + { + public TaskCompletionSource FirstProvisionStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowFirstProvisionToThrow { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) => Task.FromResult(false); + + public async Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + { + FirstProvisionStarted.TrySetResult(); + await AllowFirstProvisionToThrow.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("boom"); + } + } + + private sealed class ThrowingTestBicepProvisioner : IBicepProvisioner + { + public Task ConfigureResourceAsync(AzureBicepResource resource, CancellationToken cancellationToken) => Task.FromResult(false); + + public Task GetOrCreateResourceAsync(AzureBicepResource resource, ProvisioningContext context, CancellationToken cancellationToken) + => Task.FromException(new InvalidOperationException("boom")); + } + + private sealed class CancelConflictArmClientProvider : IArmClientProvider + { + public IArmClient GetArmClient(global::Azure.Core.TokenCredential credential, string subscriptionId) + => new CancelConflictArmClient(); + + public IArmClient GetArmClient(global::Azure.Core.TokenCredential credential) + => new CancelConflictArmClient(); + } + + private sealed class CancelConflictArmClient : IArmClient + { + public Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) + => throw new NotSupportedException(); + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + => throw new RequestFailedException(409, "The deployment is already completed."); + + public async IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class BlockingDeleteArmClientProvider(BlockingDeleteArmClient armClient) : IArmClientProvider + { + public IArmClient GetArmClient(TokenCredential credential, string subscriptionId) + => armClient; + + public IArmClient GetArmClient(TokenCredential credential) + => armClient; + } + + private sealed class BlockingDeleteArmClient : IArmClient + { + private readonly object _lock = new(); + private readonly List _deletedResourceIds = []; + + public TaskCompletionSource DeleteStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowDeleteToComplete { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public IReadOnlyList DeletedResourceIds + { + get + { + lock (_lock) + { + return [.. _deletedResourceIds]; + } + } + } + + public Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) + => throw new NotSupportedException(); + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + => Task.FromResult(true); + + public async Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _deletedResourceIds.Add(resourceId); + } + + DeleteStarted.TrySetResult(); + await AllowDeleteToComplete.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public async IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class CredentialUnavailableArmClientProvider : IArmClientProvider + { + public IArmClient GetArmClient(global::Azure.Core.TokenCredential credential, string subscriptionId) + => new CredentialUnavailableArmClient(); + + public IArmClient GetArmClient(global::Azure.Core.TokenCredential credential) + => new CredentialUnavailableArmClient(); + } + + private sealed class CredentialUnavailableArmClient : IArmClient + { + public Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IRoleAssignmentCollection GetRoleAssignments(global::Azure.Core.ResourceIdentifier scope) + => throw new NotSupportedException(); + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + => Task.FromException(new global::Azure.Identity.CredentialUnavailableException("Credential unavailable.")); + + public Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class ThrowingResourceProbeArmClientProvider(RequestFailedException exception) : IArmClientProvider + { + public IArmClient GetArmClient(TokenCredential credential, string subscriptionId) + => new ThrowingResourceProbeArmClient(exception); + + public IArmClient GetArmClient(TokenCredential credential) + => new ThrowingResourceProbeArmClient(exception); + } + + private sealed class ThrowingResourceProbeArmClient(RequestFailedException exception) : IArmClient + { + private readonly TestArmClient _inner = new(); + + public Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default) + => _inner.GetSubscriptionAndTenantAsync(cancellationToken); + + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + => _inner.GetAvailableTenantsAsync(cancellationToken); + + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) + => _inner.GetAvailableSubscriptionsAsync(cancellationToken); + + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + => _inner.GetAvailableSubscriptionsAsync(tenantId, cancellationToken); + + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) + => _inner.GetAvailableLocationsAsync(subscriptionId, cancellationToken); + + public Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default) + => _inner.GetAvailableResourceGroupsWithLocationAsync(subscriptionId, cancellationToken); + + public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) + => _inner.GetRoleAssignments(scope); + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + => Task.FromException(exception); + + public Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + => _inner.DeleteResourceAsync(resourceId, cancellationToken); + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + => _inner.CancelDeploymentAsync(deploymentId, cancellationToken); + + public IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, CancellationToken cancellationToken = default) + => _inner.GetDeploymentTargetResourceIdsAsync(deploymentId, cancellationToken); + } + + private sealed class DeleteResourceFailureArmClientProvider(string existingResourceId, RequestFailedException deleteException) : IArmClientProvider + { + public IArmClient GetArmClient(TokenCredential credential, string subscriptionId) + => new DeleteResourceFailureArmClient(existingResourceId, deleteException); + + public IArmClient GetArmClient(TokenCredential credential) + => new DeleteResourceFailureArmClient(existingResourceId, deleteException); + } + + private sealed class DeleteResourceFailureArmClient(string existingResourceId, RequestFailedException deleteException) : IArmClient + { + private readonly TestArmClient _inner = new(); + + public Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default) + => _inner.GetSubscriptionAndTenantAsync(cancellationToken); + + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + => _inner.GetAvailableTenantsAsync(cancellationToken); + + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) + => _inner.GetAvailableSubscriptionsAsync(cancellationToken); + + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + => _inner.GetAvailableSubscriptionsAsync(tenantId, cancellationToken); + + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) + => _inner.GetAvailableLocationsAsync(subscriptionId, cancellationToken); + + public Task> GetAvailableResourceGroupsWithLocationAsync(string subscriptionId, CancellationToken cancellationToken = default) + => _inner.GetAvailableResourceGroupsWithLocationAsync(subscriptionId, cancellationToken); + + public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) + => _inner.GetRoleAssignments(scope); + + public Task ResourceExistsAsync(string resourceId, CancellationToken cancellationToken = default) + => Task.FromResult(string.Equals(resourceId, existingResourceId, StringComparison.OrdinalIgnoreCase)); + + public Task DeleteResourceAsync(string resourceId, CancellationToken cancellationToken = default) + => string.Equals(resourceId, existingResourceId, StringComparison.OrdinalIgnoreCase) + ? Task.FromException(deleteException) + : _inner.DeleteResourceAsync(resourceId, cancellationToken); + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + => _inner.CancelDeploymentAsync(deploymentId, cancellationToken); + + public IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, CancellationToken cancellationToken = default) + => _inner.GetDeploymentTargetResourceIdsAsync(deploymentId, cancellationToken); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs index 78d0dedc138..c8d160e3537 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs @@ -5,10 +5,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; @@ -83,6 +85,78 @@ 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_RehydratesStringBooleanAllowResourceGroupCreation() + { + 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 azureSection = await deploymentStateManager.AcquireSectionAsync("Azure", CancellationToken.None); + azureSection.Data["SubscriptionId"] = "12345678-1234-1234-1234-123456789012"; + azureSection.Data["Location"] = "westus3"; + azureSection.Data["ResourceGroup"] = "rehydrated-rg"; + azureSection.Data["AllowResourceGroupCreation"] = "true"; + await deploymentStateManager.SaveSectionAsync(azureSection, CancellationToken.None); + + var provider = new RunModeProvisioningContextProvider( + _defaultInteractionService, + options, + environment, + logger, + armClientProvider, + userPrincipalProvider, + tokenCredentialProvider, + deploymentStateManager, + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + + var context = await provider.CreateProvisioningContextAsync(CancellationToken.None); + + Assert.Equal("westus3", context.Location.Name); + Assert.Equal("rehydrated-rg", context.ResourceGroup.Name); + Assert.True(optionValues.AllowResourceGroupCreation); + } + [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 9a7a0e45306..e7d28677d7d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -63,6 +63,10 @@ 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 IArmClientProvider CreateArmClientProvider(IEnumerable existingResourceIds, List? deletedResourceIds, IEnumerable? deploymentTargetResourceIds, List? canceledDeploymentIds) => new TestArmClientProvider(existingResourceIds: existingResourceIds, deletedResourceIds: deletedResourceIds, deploymentTargetResourceIds: deploymentTargetResourceIds, canceledDeploymentIds: canceledDeploymentIds); + public static IArmClientProvider CreateArmClientProviderForMissingResourceGroup() => new TestArmClientProvider(resourceGroupLookupReturnsNotFound: true); public static ITokenCredentialProvider CreateTokenCredentialProvider() => new TestTokenCredentialProvider(); public static ISecretClientProvider CreateSecretClientProvider() => new TestSecretClientProvider(CreateTokenCredentialProvider()); public static IBicepCompiler CreateBicepCompiler() => new TestBicepCompiler(); @@ -184,12 +188,19 @@ internal sealed class TestArmClient : IArmClient private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; private readonly TestResourceGroupResource? _resourceGroup; + private readonly HashSet? _existingResourceIds; + private readonly List? _deletedResourceIds; + private readonly IEnumerable? _deploymentTargetResourceIds; + private readonly List? _canceledDeploymentIds; + private readonly bool _resourceGroupLookupReturnsNotFound; + public TestRoleAssignmentCollection RoleAssignments { get; } = new(); - public TestArmClient(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null) + public TestArmClient(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null, bool resourceGroupLookupReturnsNotFound = false) { _deploymentOutputs = deploymentOutputs; _resourceGroup = resourceGroup; + _resourceGroupLookupReturnsNotFound = resourceGroupLookupReturnsNotFound; } public TestArmClient(Func> deploymentOutputsProvider) @@ -197,7 +208,19 @@ public TestArmClient(Func> deploymentOutputsP _deploymentOutputsProvider = deploymentOutputsProvider; } - public TestArmClient() : this([]) + public TestArmClient( + IEnumerable existingResourceIds, + List? deletedResourceIds = null, + IEnumerable? deploymentTargetResourceIds = null, + List? canceledDeploymentIds = null) + { + _existingResourceIds = new HashSet(existingResourceIds, StringComparer.OrdinalIgnoreCase); + _deletedResourceIds = deletedResourceIds; + _deploymentTargetResourceIds = deploymentTargetResourceIds; + _canceledDeploymentIds = canceledDeploymentIds; + } + + public TestArmClient() : this(new Dictionary()) { } @@ -210,7 +233,7 @@ public TestArmClient() : this([]) } else { - subscription = new TestSubscriptionResource(_deploymentOutputs!, _resourceGroup); + subscription = new TestSubscriptionResource(_deploymentOutputs!, _resourceGroup, _resourceGroupLookupReturnsNotFound); } var tenant = new TestTenantResource(); return Task.FromResult<(ISubscriptionResource, ITenantResource)>((subscription, tenant)); @@ -252,7 +275,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); } @@ -272,6 +296,42 @@ public IRoleAssignmentCollection GetRoleAssignments(ResourceIdentifier scope) { return RoleAssignments; } + + 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; + } + + public Task CancelDeploymentAsync(string deploymentId, CancellationToken cancellationToken = default) + { + _canceledDeploymentIds?.Add(deploymentId); + return Task.CompletedTask; + } + + public async IAsyncEnumerable GetDeploymentTargetResourceIdsAsync(string deploymentId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = deploymentId; + await Task.CompletedTask; + + if (_deploymentTargetResourceIds is null) + { + yield break; + } + + foreach (var resourceId in _deploymentTargetResourceIds) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return resourceId; + } + } } /// @@ -282,11 +342,13 @@ internal sealed class TestSubscriptionResource : ISubscriptionResource private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; private readonly TestResourceGroupResource? _resourceGroup; + private readonly bool _resourceGroupLookupReturnsNotFound; - public TestSubscriptionResource(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null) + public TestSubscriptionResource(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null, bool resourceGroupLookupReturnsNotFound = false) { _deploymentOutputs = deploymentOutputs; _resourceGroup = resourceGroup; + _resourceGroupLookupReturnsNotFound = resourceGroupLookupReturnsNotFound; } public TestSubscriptionResource(Func> deploymentOutputsProvider) @@ -317,7 +379,7 @@ public IResourceGroupCollection GetResourceGroups() { return new TestResourceGroupCollection(_deploymentOutputsProvider); } - return new TestResourceGroupCollection(_deploymentOutputs!, _resourceGroup); + return new TestResourceGroupCollection(_deploymentOutputs!, _resourceGroup, _resourceGroupLookupReturnsNotFound); } } @@ -329,11 +391,13 @@ internal sealed class TestResourceGroupCollection : IResourceGroupCollection private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; private readonly TestResourceGroupResource? _resourceGroup; + private readonly bool _resourceGroupLookupReturnsNotFound; - public TestResourceGroupCollection(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null) + public TestResourceGroupCollection(Dictionary deploymentOutputs, TestResourceGroupResource? resourceGroup = null, bool resourceGroupLookupReturnsNotFound = false) { _deploymentOutputs = deploymentOutputs; _resourceGroup = resourceGroup; + _resourceGroupLookupReturnsNotFound = resourceGroupLookupReturnsNotFound; } public TestResourceGroupCollection(Func> deploymentOutputsProvider) @@ -347,6 +411,11 @@ public TestResourceGroupCollection() : this([]) public Task> GetAsync(string resourceGroupName, CancellationToken cancellationToken = default) { + if (_resourceGroupLookupReturnsNotFound) + { + throw new RequestFailedException(404, $"Resource group '{resourceGroupName}' was not found."); + } + if (_resourceGroup is not null) { return Task.FromResult(Response.FromValue(_resourceGroup, new MockResponse(200))); @@ -388,6 +457,7 @@ internal sealed class TestResourceGroupResource : IResourceGroupResource private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; private readonly string _name; + private readonly RequestFailedException? _deleteException; public TestResourceGroupResource(string name, Dictionary deploymentOutputs) { @@ -405,6 +475,14 @@ public TestResourceGroupResource(string name = "test-rg") : this(name, []) { } + public TestResourceGroupResource(string name, RequestFailedException deleteException) + : this(name) + { + _deleteException = deleteException; + } + + 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; @@ -420,10 +498,16 @@ public IArmDeploymentCollection GetArmDeployments() public bool WasDeleteCalled { get; private set; } public bool WasGetResourcesCalled { get; private set; } - public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) + public Task DeleteAsync(WaitUntil waitUntil, CancellationToken cancellationToken = default) { WasDeleteCalled = true; - return Task.CompletedTask; + DeleteCallCount++; + if (_deleteException is not null) + { + return Task.FromException(_deleteException); + } + + return Task.FromResult(new TestDeleteArmOperation()); } public async IAsyncEnumerable<(string Name, string ResourceType)> GetResourcesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -501,6 +585,8 @@ public Task> CreateOrUpdateAsync( var operation = new TestArmOperation(deployment); return Task.FromResult>(operation); } + + public Task CancelAsync(string deploymentName, CancellationToken cancellationToken = default) => Task.CompletedTask; } /// @@ -532,6 +618,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. /// @@ -540,17 +638,20 @@ internal sealed class TestArmDeploymentResource : ArmDeploymentResource private readonly string _name; private readonly Dictionary? _deploymentData; private readonly Func>? _deploymentDataProvider; + private readonly ResourcesProvisioningState _provisioningState; - public TestArmDeploymentResource(string name, Dictionary deploymentData) + public TestArmDeploymentResource(string name, Dictionary deploymentData, ResourcesProvisioningState? provisioningState = null) { _name = name; _deploymentData = deploymentData; + _provisioningState = provisioningState ?? ResourcesProvisioningState.Succeeded; } - public TestArmDeploymentResource(string name, Func> deploymentDataProvider) + public TestArmDeploymentResource(string name, Func> deploymentDataProvider, ResourcesProvisioningState? provisioningState = null) { _name = name; _deploymentDataProvider = deploymentDataProvider; + _provisioningState = provisioningState ?? ResourcesProvisioningState.Succeeded; } public override ResourceIdentifier Id => new ResourceIdentifier($"/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.Resources/deployments/{_name}"); @@ -568,7 +669,7 @@ public override ArmDeploymentData Data { data = _deploymentData!; } - return ArmResourcesModelFactory.ArmDeploymentData(Id, _name, properties: ArmResourcesModelFactory.ArmDeploymentPropertiesExtended(provisioningState: ResourcesProvisioningState.Succeeded, outputs: BinaryData.FromObjectAsJson(data))); + return ArmResourcesModelFactory.ArmDeploymentData(Id, _name, properties: ArmResourcesModelFactory.ArmDeploymentPropertiesExtended(provisioningState: _provisioningState, outputs: BinaryData.FromObjectAsJson(data))); } } @@ -605,6 +706,11 @@ internal sealed class TestArmClientProvider : IArmClientProvider private readonly Dictionary? _deploymentOutputs; private readonly Func>? _deploymentOutputsProvider; private readonly TestResourceGroupResource? _resourceGroup; + private readonly IEnumerable? _existingResourceIds; + private readonly List? _deletedResourceIds; + private readonly IEnumerable? _deploymentTargetResourceIds; + private readonly List? _canceledDeploymentIds; + private readonly bool _resourceGroupLookupReturnsNotFound; public TestArmClientProvider(Dictionary deploymentOutputs) { @@ -622,7 +728,25 @@ public TestArmClientProvider(TestResourceGroupResource resourceGroup) _deploymentOutputs = []; } - public TestArmClientProvider() : this([]) + public TestArmClientProvider(bool resourceGroupLookupReturnsNotFound) + { + _resourceGroupLookupReturnsNotFound = resourceGroupLookupReturnsNotFound; + _deploymentOutputs = []; + } + + public TestArmClientProvider( + IEnumerable existingResourceIds, + List? deletedResourceIds = null, + IEnumerable? deploymentTargetResourceIds = null, + List? canceledDeploymentIds = null) + { + _existingResourceIds = existingResourceIds; + _deletedResourceIds = deletedResourceIds; + _deploymentTargetResourceIds = deploymentTargetResourceIds; + _canceledDeploymentIds = canceledDeploymentIds; + } + + public TestArmClientProvider() : this(new Dictionary()) { } @@ -632,7 +756,11 @@ public IArmClient GetArmClient(TokenCredential credential, string subscriptionId { return new TestArmClient(_deploymentOutputsProvider); } - return new TestArmClient(_deploymentOutputs!, _resourceGroup); + if (_existingResourceIds is not null) + { + return new TestArmClient(_existingResourceIds, _deletedResourceIds, _deploymentTargetResourceIds, _canceledDeploymentIds); + } + return new TestArmClient(_deploymentOutputs!, _resourceGroup, _resourceGroupLookupReturnsNotFound); } public IArmClient GetArmClient(TokenCredential credential) diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt index e460591bead..a2bcdea5cc5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithAzureResourceDependencies_DoesNotHang_step=diagnostics.verified.txt @@ -43,11 +43,11 @@ Steps with no dependencies run first, followed by steps that depend on them. 27. deploy 28. deploy-api 29. destroy-prereq - 30. destroy-azure-azure634f9 + 30. destroy-azure-azure-environment 31. destroy 32. diagnostics 33. publish-prereq - 34. publish-azure634f9 + 34. publish-azure-environment 35. validate-appservice-config-env 36. publish 37. publish-manifest @@ -61,7 +61,7 @@ Shows each step's dependencies, associated resources, tags, and descriptions. Step: azure-prepare-resources Description: Prepares the Azure resources. Dependencies: none - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: before-start Description: Aggregation step for operations that run before the application starts. @@ -88,7 +88,7 @@ Step: check-container-runtime Step: create-provisioning-context Description: Creates the Azure provisioning context for infrastructure deployment. Dependencies: ✓ deploy-prereq, ✓ validate-azure-login - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: deploy Description: Aggregation step for all deploy operations. All deploy steps should be required by this step. @@ -106,12 +106,12 @@ Step: deploy-prereq Step: destroy Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. - Dependencies: ✓ destroy-azure-azure634f9 + Dependencies: ✓ destroy-azure-azure-environment -Step: destroy-azure-azure634f9 - Description: Destroys the Azure resource group and all resources for azure634f9. +Step: destroy-azure-azure-environment + Description: Destroys the Azure resource group and all resources for azure-environment. Dependencies: ✓ destroy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: destroy-prereq Description: Prerequisite step that runs before any destroy operations. @@ -168,7 +168,7 @@ Step: provision-api-website Step: provision-azure-bicep-resources Description: Aggregation step for all Azure infrastructure provisioning operations. Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-api-identity, ✓ provision-api-roles-kv, ✓ provision-api-website, ✓ provision-env, ✓ provision-env-acr, ✓ provision-kv - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Tags: provision-infra Step: provision-env @@ -191,12 +191,12 @@ Step: provision-kv Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9, ✓ validate-appservice-config-env + Dependencies: ✓ publish-azure-environment, ✓ validate-appservice-config-env -Step: publish-azure634f9 - Description: Publishes the Azure environment configuration for azure634f9. +Step: publish-azure-environment + Description: Publishes the Azure environment configuration for azure-environment. Dependencies: ✓ publish-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: publish-manifest Description: Publishes the Aspire application model as a JSON manifest file. @@ -230,7 +230,7 @@ Step: validate-azure-app-service Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. Dependencies: ✓ deploy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: validate-build-only-container-references Description: Validates that build-only containers are consumed by another resource before publish or deploy. @@ -345,19 +345,19 @@ If targeting 'deploy-prereq': [1] deploy-prereq If targeting 'destroy': - Direct dependencies: destroy-azure-azure634f9 + Direct dependencies: destroy-azure-azure-environment Total steps: 3 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment [2] destroy -If targeting 'destroy-azure-azure634f9': +If targeting 'destroy-azure-azure-environment': Direct dependencies: destroy-prereq Total steps: 2 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment If targeting 'destroy-prereq': Direct dependencies: none @@ -508,21 +508,21 @@ If targeting 'provision-kv': [4] provision-kv If targeting 'publish': - Direct dependencies: publish-azure634f9, validate-appservice-config-env + Direct dependencies: publish-azure-environment, validate-appservice-config-env Total steps: 6 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 | validate-appservice-config-env (parallel) + [2] publish-azure-environment | validate-appservice-config-env (parallel) [3] publish -If targeting 'publish-azure634f9': +If targeting 'publish-azure-environment': Direct dependencies: publish-prereq Total steps: 4 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment If targeting 'publish-manifest': Direct dependencies: none diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDependencies.verified.txt index 99c37dc1b39..9cbca8c4cc3 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithFoundryAndAzureContainerApps_CreatesCorrectDependencies.verified.txt @@ -49,11 +49,11 @@ Steps with no dependencies run first, followed by steps that depend on them. 33. deploy 34. deploy-api 35. destroy-prereq - 36. destroy-azure-azure634f9 + 36. destroy-azure-azure-environment 37. destroy 38. diagnostics 39. publish-prereq - 40. publish-azure634f9 + 40. publish-azure-environment 41. publish 42. publish-manifest 43. push @@ -66,7 +66,7 @@ Shows each step's dependencies, associated resources, tags, and descriptions. Step: azure-prepare-resources Description: Prepares the Azure resources. Dependencies: none - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: before-start Description: Aggregation step for operations that run before the application starts. @@ -103,7 +103,7 @@ Step: compute-endpoints-foundry-project Step: create-provisioning-context Description: Creates the Azure provisioning context for infrastructure deployment. Dependencies: ✓ deploy-prereq, ✓ validate-azure-login - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: deploy Description: Aggregation step for all deploy operations. All deploy steps should be required by this step. @@ -126,12 +126,12 @@ Step: deploy-prereq Step: destroy Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. - Dependencies: ✓ destroy-azure-azure634f9 + Dependencies: ✓ destroy-azure-azure-environment -Step: destroy-azure-azure634f9 - Description: Destroys the Azure resource group and all resources for azure634f9. +Step: destroy-azure-azure-environment + Description: Destroys the Azure resource group and all resources for azure-environment. Dependencies: ✓ destroy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: destroy-prereq Description: Prerequisite step that runs before any destroy operations. @@ -198,7 +198,7 @@ Step: provision-api-containerapp Step: provision-azure-bicep-resources Description: Aggregation step for all Azure infrastructure provisioning operations. Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-aca-env, ✓ provision-aca-env-acr, ✓ provision-api-containerapp, ✓ provision-foundry, ✓ provision-foundry-project, ✓ provision-foundry-project-acr - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Tags: provision-infra Step: provision-foundry @@ -221,12 +221,12 @@ Step: provision-foundry-project-acr Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9 + Dependencies: ✓ publish-azure-environment -Step: publish-azure634f9 - Description: Publishes the Azure environment configuration for azure634f9. +Step: publish-azure-environment + Description: Publishes the Azure environment configuration for azure-environment. Dependencies: ✓ publish-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: publish-manifest Description: Publishes the Aspire application model as a JSON manifest file. @@ -260,7 +260,7 @@ Step: validate-azure-container-apps Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. Dependencies: ✓ deploy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: validate-build-only-container-references Description: Validates that build-only containers are consumed by another resource before publish or deploy. @@ -415,19 +415,19 @@ If targeting 'deploy-prereq': [1] deploy-prereq If targeting 'destroy': - Direct dependencies: destroy-azure-azure634f9 + Direct dependencies: destroy-azure-azure-environment Total steps: 3 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment [2] destroy -If targeting 'destroy-azure-azure634f9': +If targeting 'destroy-azure-azure-environment': Direct dependencies: destroy-prereq Total steps: 2 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment If targeting 'destroy-prereq': Direct dependencies: none @@ -596,21 +596,21 @@ If targeting 'provision-foundry-project-acr': [4] provision-foundry-project-acr If targeting 'publish': - Direct dependencies: publish-azure634f9 + Direct dependencies: publish-azure-environment Total steps: 5 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment [3] publish -If targeting 'publish-azure634f9': +If targeting 'publish-azure-environment': Direct dependencies: publish-prereq Total steps: 4 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment If targeting 'publish-manifest': Direct dependencies: none diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt index 142023e9c37..ae9ae7873e5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithMultipleComputeEnvironments_Works_step=diagnostics.verified.txt @@ -55,11 +55,11 @@ Steps with no dependencies run first, followed by steps that depend on them. 39. deploy-cache 40. deploy-python-app 41. destroy-prereq - 42. destroy-azure-azure634f9 + 42. destroy-azure-azure-environment 43. destroy 44. diagnostics 45. publish-prereq - 46. publish-azure634f9 + 46. publish-azure-environment 47. validate-appservice-config-aas-env 48. publish 49. publish-manifest @@ -73,7 +73,7 @@ Shows each step's dependencies, associated resources, tags, and descriptions. Step: azure-prepare-resources Description: Prepares the Azure resources. Dependencies: none - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: before-start Description: Aggregation step for operations that run before the application starts. @@ -105,7 +105,7 @@ Step: check-container-runtime Step: create-provisioning-context Description: Creates the Azure provisioning context for infrastructure deployment. Dependencies: ✓ deploy-prereq, ✓ validate-azure-login - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: deploy Description: Aggregation step for all deploy operations. All deploy steps should be required by this step. @@ -135,12 +135,12 @@ Step: deploy-python-app Step: destroy Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. - Dependencies: ✓ destroy-azure-azure634f9 + Dependencies: ✓ destroy-azure-azure-environment -Step: destroy-azure-azure634f9 - Description: Destroys the Azure resource group and all resources for azure634f9. +Step: destroy-azure-azure-environment + Description: Destroys the Azure resource group and all resources for azure-environment. Dependencies: ✓ destroy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: destroy-prereq Description: Prerequisite step that runs before any destroy operations. @@ -237,7 +237,7 @@ Step: provision-api-service-website Step: provision-azure-bicep-resources Description: Aggregation step for all Azure infrastructure provisioning operations. Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-aas-env, ✓ provision-aas-env-acr, ✓ provision-aca-env, ✓ provision-aca-env-acr, ✓ provision-api-service-website, ✓ provision-cache-containerapp, ✓ provision-python-app-containerapp, ✓ provision-storage - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Tags: provision-infra Step: provision-cache-containerapp @@ -260,12 +260,12 @@ Step: provision-storage Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9, ✓ validate-appservice-config-aas-env + Dependencies: ✓ publish-azure-environment, ✓ validate-appservice-config-aas-env -Step: publish-azure634f9 - Description: Publishes the Azure environment configuration for azure634f9. +Step: publish-azure-environment + Description: Publishes the Azure environment configuration for azure-environment. Dependencies: ✓ publish-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: publish-manifest Description: Publishes the Aspire application model as a JSON manifest file. @@ -307,7 +307,7 @@ Step: validate-azure-container-apps Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. Dependencies: ✓ deploy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: validate-build-only-container-references Description: Validates that build-only containers are consumed by another resource before publish or deploy. @@ -460,19 +460,19 @@ If targeting 'deploy-python-app': [10] deploy-python-app If targeting 'destroy': - Direct dependencies: destroy-azure-azure634f9 + Direct dependencies: destroy-azure-azure-environment Total steps: 3 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment [2] destroy -If targeting 'destroy-azure-azure634f9': +If targeting 'destroy-azure-azure-environment': Direct dependencies: destroy-prereq Total steps: 2 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment If targeting 'destroy-prereq': Direct dependencies: none @@ -711,21 +711,21 @@ If targeting 'provision-storage': [4] provision-storage If targeting 'publish': - Direct dependencies: publish-azure634f9, validate-appservice-config-aas-env + Direct dependencies: publish-azure-environment, validate-appservice-config-aas-env Total steps: 6 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 | validate-appservice-config-aas-env (parallel) + [2] publish-azure-environment | validate-appservice-config-aas-env (parallel) [3] publish -If targeting 'publish-azure634f9': +If targeting 'publish-azure-environment': Direct dependencies: publish-prereq Total steps: 4 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment If targeting 'publish-manifest': Direct dependencies: none diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt index 2f13d09aff7..b9edc94c95a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithPrivateEndpoints_CreatesCorrectDependencies.verified.txt @@ -56,11 +56,11 @@ Steps with no dependencies run first, followed by steps that depend on them. 40. deploy 41. deploy-api 42. destroy-prereq - 43. destroy-azure-azure634f9 + 43. destroy-azure-azure-environment 44. destroy 45. diagnostics 46. publish-prereq - 47. publish-azure634f9 + 47. publish-azure-environment 48. publish 49. publish-manifest 50. push @@ -73,7 +73,7 @@ Shows each step's dependencies, associated resources, tags, and descriptions. Step: azure-prepare-resources Description: Prepares the Azure resources. Dependencies: none - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: before-start Description: Aggregation step for operations that run before the application starts. @@ -100,7 +100,7 @@ Step: check-container-runtime Step: create-provisioning-context Description: Creates the Azure provisioning context for infrastructure deployment. Dependencies: ✓ deploy-prereq, ✓ validate-azure-login - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: deploy Description: Aggregation step for all deploy operations. All deploy steps should be required by this step. @@ -118,12 +118,12 @@ Step: deploy-prereq Step: destroy Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. - Dependencies: ✓ destroy-azure-azure634f9 + Dependencies: ✓ destroy-azure-azure-environment -Step: destroy-azure-azure634f9 - Description: Destroys the Azure resource group and all resources for azure634f9. +Step: destroy-azure-azure-environment + Description: Destroys the Azure resource group and all resources for azure-environment. Dependencies: ✓ destroy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: destroy-prereq Description: Prerequisite step that runs before any destroy operations. @@ -186,7 +186,7 @@ Step: provision-api-roles-sql Step: provision-azure-bicep-resources Description: Aggregation step for all Azure infrastructure provisioning operations. Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-api-containerapp, ✓ provision-api-identity, ✓ provision-api-roles-cosmos, ✓ provision-api-roles-sql, ✓ provision-cosmos, ✓ provision-env, ✓ provision-env-acr, ✓ provision-pe-subnet-cosmos-pe, ✓ provision-pe-subnet-files-pe, ✓ provision-pe-subnet-sql-pe, ✓ provision-privatelink-database-windows-net, ✓ provision-privatelink-documents-azure-com, ✓ provision-privatelink-file-core-windows-net, ✓ provision-sql, ✓ provision-sql-admin-identity, ✓ provision-sql-admin-identity-roles-sql-store, ✓ provision-sql-nsg, ✓ provision-sql-store, ✓ provision-vnet - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Tags: provision-infra Step: provision-cosmos @@ -281,12 +281,12 @@ Step: provision-vnet Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9 + Dependencies: ✓ publish-azure-environment -Step: publish-azure634f9 - Description: Publishes the Azure environment configuration for azure634f9. +Step: publish-azure-environment + Description: Publishes the Azure environment configuration for azure-environment. Dependencies: ✓ publish-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: publish-manifest Description: Publishes the Aspire application model as a JSON manifest file. @@ -315,7 +315,7 @@ Step: validate-azure-container-apps Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. Dependencies: ✓ deploy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: validate-build-only-container-references Description: Validates that build-only containers are consumed by another resource before publish or deploy. @@ -432,19 +432,19 @@ If targeting 'deploy-prereq': [1] deploy-prereq If targeting 'destroy': - Direct dependencies: destroy-azure-azure634f9 + Direct dependencies: destroy-azure-azure-environment Total steps: 3 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment [2] destroy -If targeting 'destroy-azure-azure634f9': +If targeting 'destroy-azure-azure-environment': Direct dependencies: destroy-prereq Total steps: 2 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment If targeting 'destroy-prereq': Direct dependencies: none @@ -752,21 +752,21 @@ If targeting 'provision-vnet': [5] provision-vnet If targeting 'publish': - Direct dependencies: publish-azure634f9 + Direct dependencies: publish-azure-environment Total steps: 5 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment [3] publish -If targeting 'publish-azure634f9': +If targeting 'publish-azure-environment': Direct dependencies: publish-prereq Total steps: 4 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment If targeting 'publish-manifest': Direct dependencies: none diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt index 6446a4704b6..a53ec5c3e33 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithRedisAccessKeyAuthentication_CreatesCorrectDependencies.verified.txt @@ -50,11 +50,11 @@ Steps with no dependencies run first, followed by steps that depend on them. 34. deploy 35. deploy-api 36. destroy-prereq - 37. destroy-azure-azure634f9 + 37. destroy-azure-azure-environment 38. destroy 39. diagnostics 40. publish-prereq - 41. publish-azure634f9 + 41. publish-azure-environment 42. validate-appservice-config-env 43. publish 44. publish-manifest @@ -68,7 +68,7 @@ Shows each step's dependencies, associated resources, tags, and descriptions. Step: azure-prepare-resources Description: Prepares the Azure resources. Dependencies: none - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: before-start Description: Aggregation step for operations that run before the application starts. @@ -95,7 +95,7 @@ Step: check-container-runtime Step: create-provisioning-context Description: Creates the Azure provisioning context for infrastructure deployment. Dependencies: ✓ deploy-prereq, ✓ validate-azure-login - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: deploy Description: Aggregation step for all deploy operations. All deploy steps should be required by this step. @@ -113,12 +113,12 @@ Step: deploy-prereq Step: destroy Description: Aggregation step for all destroy operations. All destroy steps should be required by this step. - Dependencies: ✓ destroy-azure-azure634f9 + Dependencies: ✓ destroy-azure-azure-environment -Step: destroy-azure-azure634f9 - Description: Destroys the Azure resource group and all resources for azure634f9. +Step: destroy-azure-azure-environment + Description: Destroys the Azure resource group and all resources for azure-environment. Dependencies: ✓ destroy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: destroy-prereq Description: Prerequisite step that runs before any destroy operations. @@ -187,7 +187,7 @@ Step: provision-api-website Step: provision-azure-bicep-resources Description: Aggregation step for all Azure infrastructure provisioning operations. Dependencies: ✓ create-provisioning-context, ✓ deploy-prereq, ✓ provision-api-identity, ✓ provision-api-roles-cache-kv, ✓ provision-api-roles-cosmos-kv, ✓ provision-api-roles-pg-kv, ✓ provision-api-website, ✓ provision-cache, ✓ provision-cache-kv, ✓ provision-cosmos, ✓ provision-cosmos-kv, ✓ provision-env, ✓ provision-env-acr, ✓ provision-pg, ✓ provision-pg-kv - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Tags: provision-infra Step: provision-cache @@ -240,12 +240,12 @@ Step: provision-pg-kv Step: publish Description: Aggregation step for all publish operations. All publish steps should be required by this step. - Dependencies: ✓ publish-azure634f9, ✓ validate-appservice-config-env + Dependencies: ✓ publish-azure-environment, ✓ validate-appservice-config-env -Step: publish-azure634f9 - Description: Publishes the Azure environment configuration for azure634f9. +Step: publish-azure-environment + Description: Publishes the Azure environment configuration for azure-environment. Dependencies: ✓ publish-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: publish-manifest Description: Publishes the Aspire application model as a JSON manifest file. @@ -279,7 +279,7 @@ Step: validate-azure-app-service Step: validate-azure-login Description: Validates Azure CLI authentication before deployment. Dependencies: ✓ deploy-prereq - Resource: azure634f9 (AzureEnvironmentResource) + Resource: azure-environment (AzureEnvironmentResource) Step: validate-build-only-container-references Description: Validates that build-only containers are consumed by another resource before publish or deploy. @@ -394,19 +394,19 @@ If targeting 'deploy-prereq': [1] deploy-prereq If targeting 'destroy': - Direct dependencies: destroy-azure-azure634f9 + Direct dependencies: destroy-azure-azure-environment Total steps: 3 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment [2] destroy -If targeting 'destroy-azure-azure634f9': +If targeting 'destroy-azure-azure-environment': Direct dependencies: destroy-prereq Total steps: 2 Execution order: [0] destroy-prereq - [1] destroy-azure-azure634f9 + [1] destroy-azure-azure-environment If targeting 'destroy-prereq': Direct dependencies: none @@ -632,21 +632,21 @@ If targeting 'provision-pg-kv': [4] provision-pg-kv If targeting 'publish': - Direct dependencies: publish-azure634f9, validate-appservice-config-env + Direct dependencies: publish-azure-environment, validate-appservice-config-env Total steps: 6 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 | validate-appservice-config-env (parallel) + [2] publish-azure-environment | validate-appservice-config-env (parallel) [3] publish -If targeting 'publish-azure634f9': +If targeting 'publish-azure-environment': Direct dependencies: publish-prereq Total steps: 4 Execution order: [0] process-parameters | validate-build-only-container-references (parallel) [1] publish-prereq - [2] publish-azure634f9 + [2] publish-azure-environment If targeting 'publish-manifest': Direct dependencies: none diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState.verified.txt index 22806f1aa2c..70ac1773435 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState.verified.txt @@ -7,6 +7,8 @@ "Azure:Location": "westus2", "Azure:SubscriptionId": "12345678-1234-1234-1234-123456789012", "Azure:ResourceGroup": "test-rg", + "Azure:TenantId": "87654321-4321-4321-4321-210987654321", + "Azure:Tenant": "testdomain.onmicrosoft.com", "Azure:AllowResourceGroupCreation": true }, SecondDeploymentState: @@ -17,6 +19,8 @@ "Azure:Location": "westus2", "Azure:SubscriptionId": "12345678-1234-1234-1234-123456789012", "Azure:ResourceGroup": "test-rg", + "Azure:TenantId": "87654321-4321-4321-4321-210987654321", + "Azure:Tenant": "testdomain.onmicrosoft.com", "Azure:AllowResourceGroupCreation": true } } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState_FirstDeployment.verified.txt b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState_FirstDeployment.verified.txt index 00e97eb2bf2..0dabe6f8089 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState_FirstDeployment.verified.txt +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureDeployerTests.DeployAsync_WithSavedParameters_ReloadsAllParameterTypesFromDeploymentState_FirstDeployment.verified.txt @@ -5,5 +5,7 @@ "Azure:Location": "westus2", "Azure:SubscriptionId": "12345678-1234-1234-1234-123456789012", "Azure:ResourceGroup": "test-rg", + "Azure:TenantId": "87654321-4321-4321-4321-210987654321", + "Azure:Tenant": "testdomain.onmicrosoft.com", "Azure:AllowResourceGroupCreation": true } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs index 5803909655e..08550ce3b61 100644 --- a/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs +++ b/tests/Aspire.Hosting.Tests/Backchannel/AuxiliaryBackchannelRpcTargetTests.cs @@ -329,6 +329,42 @@ await notificationService.PublishUpdateAsync(custom.Resource, s => s with await app.StopAsync().DefaultTimeout(); } + [Fact] + public async Task WaitForResourceAsync_ReturnsFailureWhenResourceHasErrorStateStyle() + { + using var builder = TestDistributedApplicationBuilder.Create(outputHelper); + + var custom = builder.AddResource(new CustomResource("storage")); + + using var app = builder.Build(); + await app.StartAsync().DefaultTimeout(); + + var notificationService = app.Services.GetRequiredService(); + await notificationService.PublishUpdateAsync(custom.Resource, s => s with + { + State = new ResourceStateSnapshot("Failed to Provision Roles", KnownResourceStateStyles.Error) + }).DefaultTimeout(); + + var target = new AuxiliaryBackchannelRpcTarget( + NullLogger.Instance, + app.Services.GetRequiredService(), + app.Services.GetRequiredService(), + app.Services); + + var result = await target.WaitForResourceAsync(new WaitForResourceRequest + { + ResourceName = custom.Resource.Name, + Status = "up", + TimeoutSeconds = 30 + }).DefaultTimeout(); + + Assert.False(result.Success); + Assert.False(result.TimedOut); + Assert.Equal("Failed to Provision Roles", result.State); + + await app.StopAsync().DefaultTimeout(); + } + [Fact] public async Task GetResourceSnapshotsAsync_MapsNonStringPropertiesAsStringsForLegacyCallers() {