diff --git a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerOptions.cs b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerOptions.cs index 1288db40276..efe0521a539 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerOptions.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerOptions.cs @@ -7,6 +7,8 @@ namespace Aspire.Hosting.Azure.Provisioning; internal sealed class AzureProvisionerOptions { + public string? TenantId { get; set; } + public string? SubscriptionId { get; set; } public string? ResourceGroup { get; set; } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs index 5cc8bc07368..fcd3a219df9 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs @@ -30,6 +30,7 @@ internal abstract partial class BaseProvisioningContextProvider( internal const string LocationName = "Location"; internal const string SubscriptionIdName = "SubscriptionId"; internal const string ResourceGroupName = "ResourceGroup"; + internal const string TenantName = "Tenant"; protected readonly IInteractionService _interactionService = interactionService; protected readonly AzureProvisionerOptions _options = options.Value; @@ -161,6 +162,10 @@ public virtual async Task CreateProvisioningContextAsync(Js 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; @@ -180,7 +185,56 @@ public virtual async Task CreateProvisioningContextAsync(Js protected abstract string GetDefaultResourceGroupName(); - protected async Task<(List>? subscriptionOptions, bool fetchSucceeded)> TryGetSubscriptionsAsync(CancellationToken cancellationToken) + protected async Task<(List>? tenantOptions, bool fetchSucceeded)> TryGetTenantsAsync(CancellationToken cancellationToken) + { + List>? tenantOptions = null; + var fetchSucceeded = false; + + try + { + var credential = _tokenCredentialProvider.TokenCredential; + var armClient = _armClientProvider.GetArmClient(credential); + var availableTenants = await armClient.GetAvailableTenantsAsync(cancellationToken).ConfigureAwait(false); + var tenantList = availableTenants.ToList(); + + if (tenantList.Count > 0) + { + tenantOptions = tenantList + .Select(t => + { + var tenantId = t.TenantId?.ToString() ?? ""; + + // Build display name: prefer DisplayName, fall back to domain, then to "Unknown" + var displayName = !string.IsNullOrEmpty(t.DisplayName) + ? t.DisplayName + : !string.IsNullOrEmpty(t.DefaultDomain) + ? t.DefaultDomain + : "Unknown"; + + // Build full description + var description = displayName; + if (!string.IsNullOrEmpty(t.DefaultDomain) && t.DisplayName != t.DefaultDomain) + { + description += $" ({t.DefaultDomain})"; + } + description += $" — {tenantId}"; + + return KeyValuePair.Create(tenantId, description); + }) + .OrderBy(kvp => kvp.Value) + .ToList(); + fetchSucceeded = true; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate available tenants. Falling back to manual input."); + } + + return (tenantOptions, fetchSucceeded); + } + + protected async Task<(List>? subscriptionOptions, bool fetchSucceeded)> TryGetSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken) { List>? subscriptionOptions = null; var fetchSucceeded = false; @@ -189,7 +243,7 @@ public virtual async Task CreateProvisioningContextAsync(Js { var credential = _tokenCredentialProvider.TokenCredential; var armClient = _armClientProvider.GetArmClient(credential); - var availableSubscriptions = await armClient.GetAvailableSubscriptionsAsync(cancellationToken).ConfigureAwait(false); + var availableSubscriptions = await armClient.GetAvailableSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false); var subscriptionList = availableSubscriptions.ToList(); if (subscriptionList.Count > 0) @@ -208,6 +262,11 @@ public virtual async Task CreateProvisioningContextAsync(Js return (subscriptionOptions, fetchSucceeded); } + protected async Task<(List>? subscriptionOptions, bool fetchSucceeded)> TryGetSubscriptionsAsync(CancellationToken cancellationToken) + { + return await TryGetSubscriptionsAsync(_options.TenantId, cancellationToken).ConfigureAwait(false); + } + protected async Task<(List> locationOptions, bool fetchSucceeded)> TryGetLocationsAsync(string subscriptionId, CancellationToken cancellationToken) { List>? locationOptions = null; diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs index 501ed2797cb..047c72ccfd8 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs @@ -50,6 +50,18 @@ private sealed class DefaultArmClient(ArmClient armClient) : IArmClient return (subscriptionResource, tenantResource); } + public async Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + { + var tenants = new List(); + + await foreach (var tenant in armClient.GetTenants().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + tenants.Add(new DefaultTenantResource(tenant)); + } + + return tenants; + } + public async Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) { var subscriptions = new List(); @@ -62,6 +74,27 @@ public async Task> GetAvailableSubscriptionsA return subscriptions; } + public async Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(tenantId)) + { + return await GetAvailableSubscriptionsAsync(cancellationToken).ConfigureAwait(false); + } + + var subscriptions = new List(); + + await foreach (var subscription in armClient.GetSubscriptions().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + // Filter subscriptions by tenant ID + if (subscription.Data.TenantId?.ToString().Equals(tenantId, StringComparison.OrdinalIgnoreCase) == true) + { + subscriptions.Add(new DefaultSubscriptionResource(subscription)); + } + } + + return subscriptions; + } + public async Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) { var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false); @@ -78,6 +111,7 @@ public async Task> GetAvailableSubscriptionsA private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource { public Guid? TenantId => tenantResource.Data.TenantId; + public string? DisplayName => tenantResource.Data.DisplayName; public string? DefaultDomain => tenantResource.Data.DefaultDomain; } } diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultTokenCredentialProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultTokenCredentialProvider.cs index ad78e780dfd..88fe8607253 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultTokenCredentialProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultTokenCredentialProvider.cs @@ -15,34 +15,38 @@ internal class DefaultTokenCredentialProvider : ITokenCredentialProvider public DefaultTokenCredentialProvider( ILogger logger, - IOptions options, - DistributedApplicationExecutionContext distributedApplicationExecutionContext) + IOptions options) { _logger = logger; // Optionally configured in AppHost appSettings under "Azure" : { "CredentialSource": "AzureCli" } - var credentialSetting = options.Value.CredentialSource; - // Use AzureCli as default for publish mode when no explicit credential source is set - var credentialSource = credentialSetting switch + TokenCredential credential = options.Value.CredentialSource switch { - null or "Default" when distributedApplicationExecutionContext.IsPublishMode => "AzureCli", - _ => credentialSetting ?? "Default" - }; - - TokenCredential credential = credentialSource switch - { - "AzureCli" => new AzureCliCredential(), - "AzurePowerShell" => new AzurePowerShellCredential(), - "VisualStudio" => new VisualStudioCredential(), - "AzureDeveloperCli" => new AzureDeveloperCliCredential(), + "AzureCli" => new AzureCliCredential(new() + { + AdditionallyAllowedTenants = { "*" } + }), + "AzurePowerShell" => new AzurePowerShellCredential(new() + { + AdditionallyAllowedTenants = { "*" } + }), + "VisualStudio" => new VisualStudioCredential(new() + { + AdditionallyAllowedTenants = { "*" } + }), + "AzureDeveloperCli" => new AzureDeveloperCliCredential(new() + { + AdditionallyAllowedTenants = { "*" } + }), "InteractiveBrowser" => new InteractiveBrowserCredential(), _ => new DefaultAzureCredential(new DefaultAzureCredentialOptions() { ExcludeManagedIdentityCredential = true, ExcludeWorkloadIdentityCredential = true, ExcludeAzurePowerShellCredential = true, - CredentialProcessTimeout = TimeSpan.FromSeconds(15) + CredentialProcessTimeout = TimeSpan.FromSeconds(15), + AdditionallyAllowedTenants = { "*" } }) }; diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs index 9055e9c8683..3c817043624 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs @@ -70,11 +70,21 @@ internal interface IArmClient /// Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default); + /// + /// Gets all tenants accessible to the current user. + /// + Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default); + /// /// Gets all subscriptions accessible to the current user. /// Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default); + /// + /// Gets all subscriptions accessible to the current user filtered by tenant ID. + /// + Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default); + /// /// Gets all available locations for the specified subscription. /// @@ -174,6 +184,11 @@ internal interface ITenantResource /// Guid? TenantId { get; } + /// + /// Gets the display name. + /// + string? DisplayName { get; } + /// /// Gets the default domain. /// diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs index eb5f55960e3..29b5511ffa1 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs @@ -77,6 +77,16 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati { while (_options.Location == null || _options.SubscriptionId == null) { + // Skip tenant prompting if subscription ID is already set + if (_options.TenantId == null && _options.SubscriptionId == null) + { + await PromptForTenantAsync(cancellationToken).ConfigureAwait(false); + if (_options.TenantId == null) + { + continue; + } + } + if (_options.SubscriptionId == null) { await PromptForSubscriptionAsync(cancellationToken).ConfigureAwait(false); @@ -97,6 +107,105 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati } } + private async Task PromptForTenantAsync(CancellationToken cancellationToken) + { + List>? tenantOptions = null; + var fetchSucceeded = false; + + var step = await activityReporter.CreateStepAsync( + "fetch-tenant", + cancellationToken).ConfigureAwait(false); + + await using (step.ConfigureAwait(false)) + { + try + { + var task = await step.CreateTaskAsync("Fetching available tenants", cancellationToken).ConfigureAwait(false); + + await using (task.ConfigureAwait(false)) + { + (tenantOptions, fetchSucceeded) = await TryGetTenantsAsync(cancellationToken).ConfigureAwait(false); + } + + if (fetchSucceeded) + { + await step.SucceedAsync($"Found {tenantOptions!.Count} available tenant(s)", cancellationToken).ConfigureAwait(false); + } + else + { + await step.WarnAsync("Failed to fetch tenants, falling back to manual entry", cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve Azure tenant information."); + await step.FailAsync($"Failed to retrieve tenant information: {ex.Message}", cancellationToken).ConfigureAwait(false); + throw; + } + } + + if (tenantOptions?.Count > 0) + { + var result = await _interactionService.PromptInputsAsync( + AzureProvisioningStrings.TenantDialogTitle, + AzureProvisioningStrings.TenantSelectionMessage, + [ + new InteractionInput + { + Name = TenantName, + InputType = InputType.Choice, + Label = AzureProvisioningStrings.TenantLabel, + Required = true, + Options = [..tenantOptions] + } + ], + new InputsDialogInteractionOptions + { + EnableMessageMarkdown = false + }, + cancellationToken).ConfigureAwait(false); + + if (!result.Canceled) + { + _options.TenantId = result.Data[TenantName].Value; + return; + } + } + + var manualResult = await _interactionService.PromptInputsAsync( + AzureProvisioningStrings.TenantDialogTitle, + AzureProvisioningStrings.TenantManualEntryMessage, + [ + new InteractionInput + { + Name = TenantName, + InputType = InputType.SecretText, + Label = AzureProvisioningStrings.TenantLabel, + Placeholder = AzureProvisioningStrings.TenantPlaceholder, + Required = true + } + ], + new InputsDialogInteractionOptions + { + EnableMessageMarkdown = false, + ValidationCallback = static (validationContext) => + { + var tenantInput = validationContext.Inputs[TenantName]; + if (!Guid.TryParse(tenantInput.Value, out var _)) + { + validationContext.AddValidationError(tenantInput, AzureProvisioningStrings.ValidationTenantIdInvalid); + } + return Task.CompletedTask; + } + }, + cancellationToken).ConfigureAwait(false); + + if (!manualResult.Canceled) + { + _options.TenantId = manualResult.Data[TenantName].Value; + } + } + private async Task PromptForSubscriptionAsync(CancellationToken cancellationToken) { List>? subscriptionOptions = null; @@ -114,7 +223,7 @@ private async Task PromptForSubscriptionAsync(CancellationToken cancellationToke await using (task.ConfigureAwait(false)) { - (subscriptionOptions, fetchSucceeded) = await TryGetSubscriptionsAsync(cancellationToken).ConfigureAwait(false); + (subscriptionOptions, fetchSucceeded) = await TryGetSubscriptionsAsync(_options.TenantId, cancellationToken).ConfigureAwait(false); } if (fetchSucceeded) diff --git a/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs b/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs index a1556d01051..679998d6384 100644 --- a/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs +++ b/src/Aspire.Hosting.Azure/Provisioning/Internal/RunModeProvisioningContextProvider.cs @@ -65,7 +65,7 @@ private void EnsureProvisioningOptions() (!string.IsNullOrEmpty(_options.Location) && !string.IsNullOrEmpty(_options.SubscriptionId))) { // If the interaction service is not available, or - // if both options are already set, we can skip the prompt + // if all options are already set, we can skip the prompt _provisioningOptionsAvailable.TrySetResult(); return; } @@ -120,66 +120,122 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati if (messageBarResult.Data) { - var result = await _interactionService.PromptInputsAsync( - AzureProvisioningStrings.InputsTitle, - AzureProvisioningStrings.InputsMessage, - [ - new InteractionInput + var inputs = new List(); + + // 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 { - Name = SubscriptionIdName, - InputType = InputType.Choice, - Label = AzureProvisioningStrings.SubscriptionIdLabel, - Required = true, - AllowCustomChoice = true, - Placeholder = AzureProvisioningStrings.SubscriptionIdPlaceholder, - DynamicLoading = new InputLoadOptions + LoadCallback = async (context) => { - LoadCallback = async (context) => - { - var (subscriptionOptions, fetchSucceeded) = - await TryGetSubscriptionsAsync(cancellationToken).ConfigureAwait(false); + var (tenantOptions, fetchSucceeded) = + await TryGetTenantsAsync(cancellationToken).ConfigureAwait(false); - context.Input.Options = fetchSucceeded - ? subscriptionOptions! - : []; - } + context.Input.Options = fetchSucceeded + ? tenantOptions! + : []; } - }, - new InteractionInput + } + }); + } + + // 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 + + inputs.Add(new InteractionInput + { + Name = SubscriptionIdName, + InputType = string.IsNullOrEmpty(_options.SubscriptionId) ? InputType.Choice : InputType.Text, + Label = AzureProvisioningStrings.SubscriptionIdLabel, + Required = true, + AllowCustomChoice = true, + Placeholder = AzureProvisioningStrings.SubscriptionIdPlaceholder, + Disabled = !string.IsNullOrEmpty(_options.SubscriptionId), + Value = _options.SubscriptionId, + DynamicLoading = new InputLoadOptions + { + LoadCallback = async (context) => { - Name = LocationName, - InputType = InputType.Choice, - Label = AzureProvisioningStrings.LocationLabel, - Placeholder = AzureProvisioningStrings.LocationPlaceholder, - Required = true, - Disabled = true, - DynamicLoading = new InputLoadOptions + if (!string.IsNullOrEmpty(_options.SubscriptionId)) { - LoadCallback = async (context) => - { - var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty; + // If subscription ID is not set, we don't need to load options + return; + } - var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false); + // Get tenant ID from input if tenant selection is enabled, otherwise use configured value + var tenantId = context.AllInputs[TenantName].Value ?? string.Empty; - context.Input.Options = locationOptions; - context.Input.Disabled = false; - }, - DependsOnInputs = [SubscriptionIdName] - } + var (subscriptionOptions, fetchSucceeded) = + await TryGetSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false); + + context.Input.Options = fetchSucceeded + ? subscriptionOptions! + : []; + context.Input.Disabled = false; }, - new InteractionInput + DependsOnInputs = string.IsNullOrEmpty(_options.SubscriptionId) ? [TenantName] : [] + } + }); + + 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) => { - Name = ResourceGroupName, - InputType = InputType.Text, - Label = AzureProvisioningStrings.ResourceGroupLabel, - Value = GetDefaultResourceGroupName() - } - ], + var subscriptionId = context.AllInputs[SubscriptionIdName].Value ?? string.Empty; + + var (locationOptions, _) = await TryGetLocationsAsync(subscriptionId, cancellationToken).ConfigureAwait(false); + + context.Input.Options = locationOptions; + context.Input.Disabled = false; + }, + DependsOnInputs = [SubscriptionIdName] + } + }); + + inputs.Add(new InteractionInput + { + Name = ResourceGroupName, + InputType = InputType.Text, + Label = AzureProvisioningStrings.ResourceGroupLabel, + Value = GetDefaultResourceGroupName() + }); + + var result = await _interactionService.PromptInputsAsync( + AzureProvisioningStrings.InputsTitle, + AzureProvisioningStrings.InputsMessage, + inputs, new InputsDialogInteractionOptions { EnableMessageMarkdown = true, - ValidationCallback = static (validationContext) => + ValidationCallback = (validationContext) => { + // Only validate tenant if it's included in the inputs + if (validationContext.Inputs.TryGetByName(TenantName, out var tenantInput)) + { + if (!string.IsNullOrWhiteSpace(tenantInput.Value) && !Guid.TryParse(tenantInput.Value, out _)) + { + validationContext.AddValidationError(tenantInput, AzureProvisioningStrings.ValidationTenantIdInvalid); + } + } + var subscriptionInput = validationContext.Inputs[SubscriptionIdName]; if (!string.IsNullOrWhiteSpace(subscriptionInput.Value) && !Guid.TryParse(subscriptionInput.Value, out _)) { @@ -199,8 +255,13 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati 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)) + { + _options.TenantId = tenantInput.Value; + } _options.Location = result.Data[LocationName].Value; - _options.SubscriptionId = result.Data[SubscriptionIdName].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. diff --git a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs index 2fac120c3d5..b6e0b80a8d5 100644 --- a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs +++ b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.Designer.cs @@ -214,5 +214,59 @@ internal static string LocationSelectionMessage { return ResourceManager.GetString("LocationSelectionMessage", resourceCulture); } } + + /// + /// Looks up a localized string similar to Azure tenant. + /// + internal static string TenantDialogTitle { + get { + return ResourceManager.GetString("TenantDialogTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select your Azure tenant:. + /// + internal static string TenantSelectionMessage { + get { + return ResourceManager.GetString("TenantSelectionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enter your Azure tenant ID:. + /// + internal static string TenantManualEntryMessage { + get { + return ResourceManager.GetString("TenantManualEntryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tenant ID. + /// + internal static string TenantLabel { + get { + return ResourceManager.GetString("TenantLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select tenant ID. + /// + internal static string TenantPlaceholder { + get { + return ResourceManager.GetString("TenantPlaceholder", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Tenant ID must be a valid GUID. + /// + internal static string ValidationTenantIdInvalid { + get { + return ResourceManager.GetString("ValidationTenantIdInvalid", 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 5936549bc4e..8ce03f15d66 100644 --- a/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx +++ b/src/Aspire.Hosting.Azure/Resources/AzureProvisioningStrings.resx @@ -171,4 +171,22 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Select your Azure location and specify resource group: + + Azure tenant + + + Select your Azure tenant: + + + Enter your Azure tenant ID: + + + Tenant ID + + + Select tenant ID + + + Tenant ID must be a valid GUID. + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf index 5545e951233..c6e941522c9 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.cs.xlf @@ -81,6 +81,31 @@ Zjistěte více v [dokumentaci k nasazení Azure](https://aka.ms/dotnet/aspire/a Vyberte předplatné Azure: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Název skupiny prostředků musí být platný název skupiny prostředků Azure. @@ -91,6 +116,11 @@ Zjistěte více v [dokumentaci k nasazení Azure](https://aka.ms/dotnet/aspire/a ID předplatného musí být platným identifikátorem GUID. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf index c496be0cc2d..8b56b473ae9 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.de.xlf @@ -81,6 +81,31 @@ Weitere Informationen finden Sie in der [Azure-Bereitstellungsdokumentation](htt Wählen Sie Ihr Azure-Abonnement aus: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Der Ressourcengruppenname muss ein gültiger Azure-Ressourcengruppenname sein. @@ -91,6 +116,11 @@ Weitere Informationen finden Sie in der [Azure-Bereitstellungsdokumentation](htt Die Abonnement-ID muss eine gültige GUID sein. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf index 1dfeda8f00e..bfa50ecde2a 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.es.xlf @@ -81,6 +81,31 @@ Para más información, consulte la [documentación de aprovisionamiento de Azur Seleccione su suscripción de Azure: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. El nombre del grupo de recursos debe ser un nombre de grupo de recursos de Azure válido. @@ -91,6 +116,11 @@ Para más información, consulte la [documentación de aprovisionamiento de Azur El identificador de suscripción debe ser un GUID válido. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf index 7d1cd4ae067..17be6f53547 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.fr.xlf @@ -81,6 +81,31 @@ Pour en savoir plus, consultez la [documentation d’approvisionnement Azure](ht Sélectionnez votre abonnement Azure : + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Le nom du groupe de ressources doit être un nom de groupe de ressources Azure valide. @@ -91,6 +116,11 @@ Pour en savoir plus, consultez la [documentation d’approvisionnement Azure](ht L’ID d’abonnement doit être un GUID valide. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf index d5298308020..c375a09e047 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.it.xlf @@ -81,6 +81,31 @@ Per altre informazioni, vedere la [documentazione sul provisioning di Azure](htt Selezionare la sottoscrizione di Azure: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Il nome del gruppo di risorse deve essere un nome valido per un gruppo di risorse di Azure. @@ -91,6 +116,11 @@ Per altre informazioni, vedere la [documentazione sul provisioning di Azure](htt L'ID sottoscrizione deve essere un GUID valido. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf index 3bf850f17cf..6425e3d26cd 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ja.xlf @@ -81,6 +81,31 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Azure サブスクリプションを選択してください: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. リソース グループ名は、有効な Azure リソース グループ名である必要があります。 @@ -91,6 +116,11 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az サブスクリプション ID は有効な GUID である必要があります。 + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf index d4a04604122..aaf3c3823f7 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ko.xlf @@ -81,6 +81,31 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Azure 구독 선택: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. 리소스 그룹 이름은 유효한 Azure 리소스 그룹 이름이어야 합니다. @@ -91,6 +116,11 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az 구독 ID는 유효한 GUID여야 합니다. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf index a3558a863ad..2fb8e819863 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pl.xlf @@ -81,6 +81,31 @@ Aby dowiedzieć się więcej, zobacz [dokumentację aprowizacji platformy Azure] Wybierz subskrypcję platformy Azure: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Nazwa grupy zasobów musi być prawidłową nazwą grupy zasobów platformy Azure. @@ -91,6 +116,11 @@ Aby dowiedzieć się więcej, zobacz [dokumentację aprowizacji platformy Azure] Identyfikator subskrypcji musi być prawidłowym identyfikatorem GUID. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file 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 61797a87ad1..0642392f426 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pt-BR.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.pt-BR.xlf @@ -81,6 +81,31 @@ Para saber mais, veja a [documentação de provisionamento do Azure](https://aka Selecione sua assinatura do Azure: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. O nome do grupo de recursos deve ser um nome válido de grupo de recursos do Azure. @@ -91,6 +116,11 @@ Para saber mais, veja a [documentação de provisionamento do Azure](https://aka A ID da assinatura deve ser um GUID válido. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf index 498bf9b2943..f7bbf002cac 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.ru.xlf @@ -81,6 +81,31 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Выберите свою подписку Azure: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Имя группы ресурсов должно быть допустимым именем группы ресурсов Azure. @@ -91,6 +116,11 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az Идентификатор подписки должен быть допустимым GUID. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf index e3e9c005e22..019eb1edb01 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.tr.xlf @@ -81,6 +81,31 @@ Daha fazla bilgi için [Azure sağlama belgelerine](https://aka.ms/dotnet/aspire Azure aboneliğinizi seçin: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. Kaynak grubu adı, geçerli bir Azure kaynak grubu adı olmalıdır. @@ -91,6 +116,11 @@ Daha fazla bilgi için [Azure sağlama belgelerine](https://aka.ms/dotnet/aspire Abonelik kimliği geçerli bir GUID olmalıdır. + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file 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 5846e508620..97e4c240b2e 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hans.xlf @@ -81,6 +81,31 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az 选择 Azure 订阅: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. 资源组名称必须是有效的 Azure 资源组名称。 @@ -91,6 +116,11 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az 订阅 ID 必须是有效的 GUID。 + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file 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 0861d82266e..ccb77145a18 100644 --- a/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting.Azure/Resources/xlf/AzureProvisioningStrings.zh-Hant.xlf @@ -81,6 +81,31 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az 選取您的 Azure 訂用帳戶: + + Azure tenant + Azure tenant + + + + Tenant ID + Tenant ID + + + + Enter your Azure tenant ID: + Enter your Azure tenant ID: + + + + Select tenant ID + Select tenant ID + + + + Select your Azure tenant: + Select your Azure tenant: + + Resource group name must be a valid Azure resource group name. 資源群組名稱必須是有效的 Azure 資源群組名稱。 @@ -91,6 +116,11 @@ To learn more, see the [Azure provisioning docs](https://aka.ms/dotnet/aspire/az 訂閱識別碼必須是有效的 GUID。 + + Tenant ID must be a valid GUID. + Tenant ID must be a valid GUID. + + \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs index b1f700de70e..5b355930bad 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureDeployerTests.cs @@ -68,6 +68,22 @@ public async Task DeployAsync_PromptsViaInteractionService() var runTask = Task.Run(app.Run); // Wait for the first interaction (subscription selection) + var tenantInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Azure tenant", tenantInteraction.Title); + Assert.False(tenantInteraction.Options!.EnableMessageMarkdown); + + Assert.Collection(tenantInteraction.Inputs, + input => + { + Assert.Equal("Tenant ID", input.Label); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.Required); + }); + + tenantInteraction.Inputs[0].Value = "87654321-4321-4321-4321-210987654321"; + tenantInteraction.CompletionTcs.SetResult(InteractionResult.Ok(tenantInteraction.Inputs)); + + // Wait for the next interaction (subscription selection) var subscriptionInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); Assert.Equal("Azure subscription", subscriptionInteraction.Title); Assert.False(subscriptionInteraction.Options!.EnableMessageMarkdown); diff --git a/tests/Aspire.Hosting.Azure.Tests/DefaultTokenCredentialProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/DefaultTokenCredentialProviderTests.cs index 1e0531fd7a8..83ad2fafb3b 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DefaultTokenCredentialProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DefaultTokenCredentialProviderTests.cs @@ -14,105 +14,123 @@ namespace Aspire.Hosting.Azure.Tests; public class DefaultTokenCredentialProviderTests { [Fact] - public void Constructor_PublishMode_NoCredentialSource_UsesAzureCli() + public void Constructor_NoCredentialSource_UsesDefaultAzureCredential() { // Arrange var azureOptions = CreateAzureOptions(credentialSource: null); - var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish); // Act var provider = new DefaultTokenCredentialProvider( NullLogger.Instance, - azureOptions, - executionContext); + azureOptions); // Assert - Assert.IsType(provider.TokenCredential); + Assert.IsType(provider.TokenCredential); } [Fact] - public void Constructor_PublishMode_ExplicitDefaultCredentialSource_UsesAzureCli() + public void Constructor_ExplicitDefaultCredentialSource_UsesDefaultAzureCredential() { // Arrange var azureOptions = CreateAzureOptions(credentialSource: "Default"); - var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish); // Act var provider = new DefaultTokenCredentialProvider( NullLogger.Instance, - azureOptions, - executionContext); + azureOptions); // Assert - Assert.IsType(provider.TokenCredential); + Assert.IsType(provider.TokenCredential); } [Fact] - public void Constructor_PublishMode_ExplicitNonDefaultCredentialSource_RespectsSource() + public void Constructor_ExplicitNonDefaultCredentialSource_RespectsSource() { // Arrange var azureOptions = CreateAzureOptions(credentialSource: "AzurePowerShell"); - var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Publish); // Act var provider = new DefaultTokenCredentialProvider( NullLogger.Instance, - azureOptions, - executionContext); + azureOptions); // Assert Assert.IsType(provider.TokenCredential); } [Fact] - public void Constructor_RunMode_NoCredentialSource_UsesDefaultAzureCredential() + public void Constructor_ExplicitCredentialSource_RespectsSource() { // Arrange - var azureOptions = CreateAzureOptions(credentialSource: null); - var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var azureOptions = CreateAzureOptions(credentialSource: "VisualStudio"); // Act var provider = new DefaultTokenCredentialProvider( NullLogger.Instance, - azureOptions, - executionContext); + azureOptions); + + // Assert + Assert.IsType(provider.TokenCredential); + } + + [Fact] + public void Constructor_InvalidCredentialSource_UsesDefaultAzureCredential() + { + // Arrange + var azureOptions = CreateAzureOptions(credentialSource: "InvalidSource"); + + // Act + var provider = new DefaultTokenCredentialProvider( + NullLogger.Instance, + azureOptions); // Assert Assert.IsType(provider.TokenCredential); } [Fact] - public void Constructor_RunMode_ExplicitCredentialSource_RespectsSource() + public void Constructor_AzureCliCredentialSource_UsesAzureCliCredential() { // Arrange - var azureOptions = CreateAzureOptions(credentialSource: "VisualStudio"); - var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var azureOptions = CreateAzureOptions(credentialSource: "AzureCli"); // Act var provider = new DefaultTokenCredentialProvider( NullLogger.Instance, - azureOptions, - executionContext); + azureOptions); // Assert - Assert.IsType(provider.TokenCredential); + Assert.IsType(provider.TokenCredential); } [Fact] - public void Constructor_InvalidCredentialSource_UsesDefaultAzureCredential() + public void Constructor_AzureDeveloperCliCredentialSource_UsesAzureDeveloperCliCredential() { // Arrange - var azureOptions = CreateAzureOptions(credentialSource: "InvalidSource"); - var executionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run); + var azureOptions = CreateAzureOptions(credentialSource: "AzureDeveloperCli"); // Act var provider = new DefaultTokenCredentialProvider( NullLogger.Instance, - azureOptions, - executionContext); + azureOptions); // Assert - Assert.IsType(provider.TokenCredential); + Assert.IsType(provider.TokenCredential); + } + + [Fact] + public void Constructor_InteractiveBrowserCredentialSource_UsesInteractiveBrowserCredential() + { + // Arrange + var azureOptions = CreateAzureOptions(credentialSource: "InteractiveBrowser"); + + // Act + var provider = new DefaultTokenCredentialProvider( + NullLogger.Instance, + azureOptions); + + // Assert + Assert.IsType(provider.TokenCredential); } private static IOptions CreateAzureOptions(string? credentialSource) diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs index 4294a921eed..7ef683d8564 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningContextProviderTests.cs @@ -277,6 +277,13 @@ public async Task CreateProvisioningContextAsync_PromptsIfNoOptions() Assert.True(inputsInteraction.Options!.EnableMessageMarkdown); Assert.Collection(inputsInteraction.Inputs, + input => + { + Assert.Equal(BaseProvisioningContextProvider.TenantName, input.Name); + Assert.Equal("Tenant ID", input.Label); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.Required); + }, input => { Assert.Equal(BaseProvisioningContextProvider.SubscriptionIdName, input.Name); @@ -386,6 +393,96 @@ public async Task CreateProvisioningContextAsync_Prompt_ValidatesSubAndResourceG Assert.True((bool)context.GetType().GetProperty("HasErrors", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(context, null)!); } + [Fact] + public async Task CreateProvisioningContextAsync_DoesNotPromptForTenantWhenSubscriptionIdProvided() + { + // Arrange + var testInteractionService = new TestInteractionService(); + var subscriptionId = "12345678-1234-1234-1234-123456789012"; + var options = ProvisioningTestHelpers.CreateOptions(subscriptionId, null, null); + var environment = ProvisioningTestHelpers.CreateEnvironment(); + var logger = ProvisioningTestHelpers.CreateLogger(); + var armClientProvider = ProvisioningTestHelpers.CreateArmClientProvider(); + var userPrincipalProvider = ProvisioningTestHelpers.CreateUserPrincipalProvider(); + var tokenCredentialProvider = ProvisioningTestHelpers.CreateTokenCredentialProvider(); + var userSecrets = new JsonObject(); + + var provider = new RunModeProvisioningContextProvider( + testInteractionService, + options, + environment, + logger, + armClientProvider, + userPrincipalProvider, + tokenCredentialProvider, + new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run)); + + // Act + var createTask = provider.CreateProvisioningContextAsync(userSecrets); + + // Assert - Wait for the first interaction (message bar) + var messageBarInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Azure provisioning", messageBarInteraction.Title); + + // Complete the message bar interaction to proceed to inputs dialog + messageBarInteraction.CompletionTcs.SetResult(InteractionResult.Ok(true)); // Data = true (user clicked Enter Values) + + // Wait for the inputs interaction + var inputsInteraction = await testInteractionService.Interactions.Reader.ReadAsync(); + Assert.Equal("Azure provisioning", inputsInteraction.Title); + Assert.True(inputsInteraction.Options!.EnableMessageMarkdown); + + // Assert that only 3 inputs are present (no tenant input since subscription is provided) + Assert.Collection(inputsInteraction.Inputs, + input => + { + Assert.Equal(BaseProvisioningContextProvider.SubscriptionIdName, input.Name); + Assert.Equal("Subscription ID", input.Label); + Assert.Equal(InputType.Text, input.InputType); + Assert.True(input.Disabled); + Assert.True(input.Required); + }, + input => + { + Assert.Equal(BaseProvisioningContextProvider.LocationName, input.Name); + Assert.Equal("Location", input.Label); + Assert.Equal(InputType.Choice, input.InputType); + Assert.True(input.Required); + }, + input => + { + Assert.Equal(BaseProvisioningContextProvider.ResourceGroupName, input.Name); + Assert.Equal("Resource group", input.Label); + Assert.Equal(InputType.Text, input.InputType); + Assert.False(input.Required); + }); + + // Trigger dynamic update of locations based on subscription. + await inputsInteraction.Inputs[BaseProvisioningContextProvider.LocationName].DynamicLoading!.LoadCallback(new LoadInputContext + { + AllInputs = inputsInteraction.Inputs, + CancellationToken = CancellationToken.None, + Input = inputsInteraction.Inputs[BaseProvisioningContextProvider.LocationName], + ServiceProvider = new ServiceCollection().BuildServiceProvider() + }); + + inputsInteraction.Inputs[BaseProvisioningContextProvider.LocationName].Value = inputsInteraction.Inputs[BaseProvisioningContextProvider.LocationName].Options!.First(kvp => kvp.Key == "westus").Value; + inputsInteraction.Inputs[BaseProvisioningContextProvider.ResourceGroupName].Value = "rg-myrg"; + + inputsInteraction.CompletionTcs.SetResult(InteractionResult.Ok(inputsInteraction.Inputs)); + + // Wait for the create task to complete + var context = await createTask; + + // Assert + Assert.NotNull(context.Tenant); + Assert.Equal(Guid.Parse("87654321-4321-4321-4321-210987654321"), context.Tenant.TenantId); + Assert.Equal("testdomain.onmicrosoft.com", context.Tenant.DefaultDomain); + Assert.Equal("/subscriptions/12345678-1234-1234-1234-123456789012", context.Subscription.Id.ToString()); + Assert.Equal("westus", context.Location.Name); + Assert.Equal("rg-myrg", context.ResourceGroup.Name); + } + [Fact] public async Task PublishMode_CreateProvisioningContextAsync_ReturnsValidContext() { diff --git a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs index 2c562b70566..7e36fc19493 100644 --- a/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs +++ b/tests/Aspire.Hosting.Azure.Tests/ProvisioningTestHelpers.cs @@ -210,6 +210,15 @@ public TestArmClient() : this([]) return Task.FromResult<(ISubscriptionResource, ITenantResource)>((subscription, tenant)); } + public Task> GetAvailableTenantsAsync(CancellationToken cancellationToken = default) + { + var tenants = new List + { + new TestTenantResource() + }; + return Task.FromResult>(tenants); + } + public Task> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default) { var subscriptions = new List @@ -219,6 +228,18 @@ public Task> GetAvailableSubscriptionsAsync(C return Task.FromResult>(subscriptions); } + public Task> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default) + { + // For testing, return the same subscription regardless of tenant filtering + _ = tenantId; // Suppress unused parameter warning + _ = cancellationToken; // Suppress unused parameter warning + var subscriptions = new List + { + new TestSubscriptionResource() + }; + return Task.FromResult>(subscriptions); + } + public Task> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default) { var locations = new List<(string Name, string DisplayName)> @@ -414,6 +435,7 @@ public Task> CreateOrUpdateAsync( internal sealed class TestTenantResource : ITenantResource { public Guid? TenantId { get; } = Guid.Parse("87654321-4321-4321-4321-210987654321"); + public string? DisplayName { get; } = "Test Tenant"; public string? DefaultDomain { get; } = "testdomain.onmicrosoft.com"; }