Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,8 +129,12 @@ private static bool OptionsEqual(List<SelectViewModel<string>> existing, List<Se

private static bool ShouldUseIncomingValue(InteractionInput current, InteractionInput incoming)
{
// Preserve local edits during ordinary updates, but accept server-provided values when
// dynamic loading completes or when the input is disabled and therefore server-owned.
return (current.Loading && !incoming.Loading) || incoming.Disabled;
// Dynamic loading can replace both the option list and the selected value. When loading
// completes, the server value is the one validated against the freshly loaded options.
//
// Disabled inputs are also server-owned because the user could not have made a meaningful local
// edit while the control was unavailable. This includes disabled -> enabled transitions, such as
// Azure Subscription ID becoming editable after tenant-specific subscriptions are loaded.
return (current.Loading && !incoming.Loading) || current.Disabled || incoming.Disabled;
}
}
73 changes: 65 additions & 8 deletions src/Aspire.Hosting.Azure/AcrLoginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#pragma warning disable ASPIRECONTAINERRUNTIME001

using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.Publishing;
Expand All @@ -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)
{
Expand All @@ -28,6 +34,7 @@ internal sealed class AcrLoginService : IAcrLoginService
private readonly IHttpClientFactory _httpClientFactory;
private readonly IContainerRuntimeResolver _containerRuntimeResolver;
private readonly ILogger<AcrLoginService> _logger;
private readonly TimeProvider _timeProvider;

private sealed class AcrRefreshTokenResponse
{
Expand All @@ -44,11 +51,17 @@ private sealed class AcrRefreshTokenResponse
/// <param name="httpClientFactory">The HTTP client factory for making OAuth2 exchange requests.</param>
/// <param name="containerRuntimeResolver">The container runtime resolver for performing registry login.</param>
/// <param name="logger">The logger for diagnostic output.</param>
public AcrLoginService(IHttpClientFactory httpClientFactory, IContainerRuntimeResolver containerRuntimeResolver, ILogger<AcrLoginService> logger)
/// <param name="timeProvider">The time provider used for retry delays.</param>
public AcrLoginService(
IHttpClientFactory httpClientFactory,
IContainerRuntimeResolver containerRuntimeResolver,
ILogger<AcrLoginService> 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));
}

/// <inheritdoc/>
Expand All @@ -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<string> ExchangeAadTokenForAcrRefreshTokenAsync(
Expand Down
4 changes: 1 addition & 3 deletions src/Aspire.Hosting.Azure/AzureBicepResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -306,7 +305,6 @@ private static async Task ProvisionAzureBicepResourceAsync(PipelineStepContext c
}

var bicepProvisioner = context.Services.GetRequiredService<IBicepProvisioner>();
var configuration = context.Services.GetRequiredService<IConfiguration>();

// Find the AzureEnvironmentResource from the application model
var azureEnvironment = context.Model.Resources.OfType<AzureEnvironmentResource>().FirstOrDefault();
Expand All @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion src/Aspire.Hosting.Azure/AzureEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public sealed class AzureEnvironmentResource : Resource
/// </summary>
public const string ProvisionInfrastructureStepName = "provision-azure-bicep-resources";

internal const string RunModeProvisionStepName = "run-mode-azure-provision";

/// <summary>
/// Gets or sets the Azure location that the resources will be deployed to.
/// </summary>
Expand Down Expand Up @@ -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 =>
{
Expand Down
49 changes: 45 additions & 4 deletions src/Aspire.Hosting.Azure/AzureEnvironmentResourceExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -33,8 +37,40 @@ public static IResourceBuilder<AzureEnvironmentResource> 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<ResourcePropertySnapshot>.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<AzureProvisioningController>(), 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<AzureProvisioningController>().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.
Expand Down Expand Up @@ -103,8 +139,13 @@ public static IResourceBuilder<AzureEnvironmentResource> 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;
}
}
Loading
Loading