From fe0b306a7e91eff5be6175a39109e0eb700c5b33 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Mon, 19 May 2025 10:30:34 +1000 Subject: [PATCH 1/5] Clean up --- .../AzureStorageExtensions.cs | 29 +++--- ...StorageExtensions.StorageQueueComponent.cs | 68 +++++++++++++ .../AspireQueueStorageExtensions.cs | 98 ++++++------------- 3 files changed, 115 insertions(+), 80 deletions(-) create mode 100644 src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 8c9235640b1..bb3213a91a8 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -132,25 +132,24 @@ public static IResourceBuilder RunAsEmulator(this IResourc }); BlobServiceClient? blobServiceClient = null; + builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { // The BlobServiceClient is created before the health check is run. // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so // we use BeforeResourceStartedEvent - var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null."); + var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) + ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null."); blobServiceClient = CreateBlobServiceClient(connectionString); }); builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { - // The ResourceReadyEvent of a resource is triggered after its health check is healthy. + // The ResourceReadyEvent of a resource is triggered after its health check (AddAzureBlobStorage) is healthy. // This means we can safely use this event to create the blob containers. - if (blobServiceClient is null) - { - throw new InvalidOperationException("BlobServiceClient is not initialized."); - } + _ = blobServiceClient ?? throw new InvalidOperationException($"{nameof(BlobServiceClient)} is not initialized."); foreach (var container in builder.Resource.BlobContainers) { @@ -159,6 +158,7 @@ public static IResourceBuilder RunAsEmulator(this IResourc } }); + // Add the "Storage" resource health check. There will be separate health checks for the nested child resources. var healthCheckKey = $"{builder.Resource.Name}_check"; builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp => @@ -284,7 +284,7 @@ public static IResourceBuilder WithApiVersionCheck /// /// Creates a builder for the which can be referenced to get the Azure Storage blob endpoint for the storage account. /// - /// The for / + /// The for . /// The name of the resource. /// An for the . public static IResourceBuilder AddBlobs(this IResourceBuilder builder, [ResourceName] string name) @@ -300,6 +300,8 @@ public static IResourceBuilder AddBlobs(this IResource connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); }); + // Add the "Blobs" resource health check. This is a separate health check from the "Storage" resource health check. + // Doing it on the storage is not sufficient as the WaitForHealthyAsync doesn't bubble up to the parent resources. var healthCheckKey = $"{resource.Name}_check"; BlobServiceClient? blobServiceClient = null; @@ -308,13 +310,15 @@ public static IResourceBuilder AddBlobs(this IResource return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized.")); }, name: healthCheckKey); - return builder.ApplicationBuilder.AddResource(resource).WithHealthCheck(healthCheckKey); + return builder.ApplicationBuilder + .AddResource(resource) + .WithHealthCheck(healthCheckKey); } /// /// Creates a builder for the which can be referenced to get the Azure Storage blob container endpoint for the storage account. /// - /// The for / + /// The for . /// The name of the resource. /// The name of the blob container. /// An for the . @@ -343,13 +347,14 @@ public static IResourceBuilder AddBlobContain name: healthCheckKey); return builder.ApplicationBuilder - .AddResource(resource).WithHealthCheck(healthCheckKey); + .AddResource(resource) + .WithHealthCheck(healthCheckKey); } /// /// Creates a builder for the which can be referenced to get the Azure Storage tables endpoint for the storage account. /// - /// The for / + /// The for . /// The name of the resource. /// An for the . public static IResourceBuilder AddTables(this IResourceBuilder builder, [ResourceName] string name) @@ -364,7 +369,7 @@ public static IResourceBuilder AddTables(this IResour /// /// Creates a builder for the which can be referenced to get the Azure Storage queues endpoint for the storage account. /// - /// The for / + /// The for . /// The name of the resource. /// An for the . public static IResourceBuilder AddQueues(this IResourceBuilder builder, [ResourceName] string name) diff --git a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs new file mode 100644 index 00000000000..ddbb7cc8263 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Aspire.Azure.Storage.Queues; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Storage.Queues; +using HealthChecks.Azure.Storage.Queues; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +public static partial class AspireQueueStorageExtensions +{ + private sealed class StorageQueueComponent : AzureComponent + { + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageQueuesSettings settings, string connectionName, + string configurationSectionName) + { + return ((IAzureClientFactoryBuilderWithCredential)azureFactoryBuilder).RegisterClientFactory((options, cred) => + { + var connectionString = settings.ConnectionString; + if (string.IsNullOrEmpty(connectionString) && settings.ServiceUri is null) + { + throw new InvalidOperationException($"A QueueServiceClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'ServiceUri' in the '{configurationSectionName}' configuration section."); + } + + return !string.IsNullOrEmpty(connectionString) + ? new QueueServiceClient(connectionString, options) + : cred is not null + ? new QueueServiceClient(settings.ServiceUri, cred, options) + : new QueueServiceClient(settings.ServiceUri, options); + }, requiresCredential: false); + } + + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) + { +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 + } + + protected override void BindSettingsToConfiguration(AzureStorageQueuesSettings settings, IConfiguration configuration) + { + configuration.Bind(settings); + } + + protected override IHealthCheck CreateHealthCheck(QueueServiceClient client, AzureStorageQueuesSettings settings) + => new AzureQueueStorageHealthCheck(client, new AzureQueueStorageHealthCheckOptions()); + + protected override bool GetHealthCheckEnabled(AzureStorageQueuesSettings settings) + => !settings.DisableHealthChecks; + + protected override TokenCredential? GetTokenCredential(AzureStorageQueuesSettings settings) + => settings.Credential; + + protected override bool GetMetricsEnabled(AzureStorageQueuesSettings settings) + => false; + + protected override bool GetTracingEnabled(AzureStorageQueuesSettings settings) + => !settings.DisableTracing; + } +} diff --git a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs index b4cc6bf2c3f..8fdcf71715e 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs @@ -1,16 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Azure.Common; using Aspire.Azure.Storage.Queues; -using Azure.Core; using Azure.Core.Extensions; using Azure.Storage.Queues; -using HealthChecks.Azure.Storage.Queues; -using Microsoft.Extensions.Azure; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Microsoft.Extensions.Hosting; @@ -18,20 +12,27 @@ namespace Microsoft.Extensions.Hosting; /// Provides extension methods for registering as a singleton in the services provided by the . /// Enables retries, corresponding health check, logging and telemetry. /// -public static class AspireQueueStorageExtensions +public static partial class AspireQueueStorageExtensions { private const string DefaultConfigSectionName = "Aspire:Azure:Storage:Queues"; /// - /// Registers as a singleton in the services provided by the . - /// Enables retries, corresponding health check, logging and telemetry. + /// Registers as a singleton in the services provided by the . + /// Enables retries, corresponding health check, logging and telemetry. /// /// The to read config from and add services to. /// A name used to retrieve the connection string from the ConnectionStrings configuration section. - /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. - /// An optional method that can be used for customizing the . + /// + /// An optional method that can be used for customizing the . It's invoked after + /// the settings are read from the configuration. + /// + /// + /// An optional method that can be used for customizing the . + /// /// Reads the configuration from "Aspire:Azure:Storage:Queues" section. - /// Thrown when neither nor is provided. + /// + /// Neither nor is provided. + /// public static void AddAzureQueueClient( this IHostApplicationBuilder builder, string connectionName, @@ -45,15 +46,26 @@ public static void AddAzureQueueClient( } /// - /// Registers as a singleton for given in the services provided by the . - /// Enables retries, corresponding health check, logging and telemetry. + /// Registers as a singleton for given in the services provided + /// by the . + /// Enables retries, corresponding health check, logging and telemetry. /// /// The to read config from and add services to. - /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. - /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. - /// An optional method that can be used for customizing the . + /// + /// The name of the component, which is used as the of the service + /// and also to retrieve the connection string from the ConnectionStrings configuration section. + /// + /// + /// An optional method that can be used for customizing the . + /// It's invoked after the settings are read from the configuration. + /// + /// + /// An optional method that can be used for customizing the . + /// /// Reads the configuration from "Aspire:Azure:Storage:Queues:{name}" section. - /// Thrown when neither nor is provided. + /// + /// Neither nor is provided. + /// public static void AddKeyedAzureQueueClient( this IHostApplicationBuilder builder, string name, @@ -65,54 +77,4 @@ public static void AddKeyedAzureQueueClient( new StorageQueueComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); } - - private sealed class StorageQueueComponent : AzureComponent - { - protected override IAzureClientBuilder AddClient( - AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageQueuesSettings settings, string connectionName, - string configurationSectionName) - { - return ((IAzureClientFactoryBuilderWithCredential)azureFactoryBuilder).RegisterClientFactory((options, cred) => - { - var connectionString = settings.ConnectionString; - if (string.IsNullOrEmpty(connectionString) && settings.ServiceUri is null) - { - throw new InvalidOperationException($"A QueueServiceClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'ServiceUri' in the '{configurationSectionName}' configuration section."); - } - - return !string.IsNullOrEmpty(connectionString) - ? new QueueServiceClient(connectionString, options) - : cred is not null - ? new QueueServiceClient(settings.ServiceUri, cred, options) - : new QueueServiceClient(settings.ServiceUri, options); - }, requiresCredential: false); - } - - protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) - { -#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works - clientBuilder.ConfigureOptions(options => configuration.Bind(options)); -#pragma warning restore IDE0200 - } - - protected override void BindSettingsToConfiguration(AzureStorageQueuesSettings settings, IConfiguration configuration) - { - configuration.Bind(settings); - } - - protected override IHealthCheck CreateHealthCheck(QueueServiceClient client, AzureStorageQueuesSettings settings) - => new AzureQueueStorageHealthCheck(client, new AzureQueueStorageHealthCheckOptions()); - - protected override bool GetHealthCheckEnabled(AzureStorageQueuesSettings settings) - => !settings.DisableHealthChecks; - - protected override TokenCredential? GetTokenCredential(AzureStorageQueuesSettings settings) - => settings.Credential; - - protected override bool GetMetricsEnabled(AzureStorageQueuesSettings settings) - => false; - - protected override bool GetTracingEnabled(AzureStorageQueuesSettings settings) - => !settings.DisableTracing; - } } From 51ed5648fc679575e3c9d9fcb6cf97a626193f26 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Wed, 7 May 2025 16:51:28 +1000 Subject: [PATCH 2/5] AzureStorage auto create queues Addendum to #5167 --- .../Program.cs | 25 ++-- .../AzureFunctionsEndToEnd.AppHost/Program.cs | 20 +-- .../MyAzureBlobTrigger.cs | 10 +- .../MyAzureQueueTrigger.cs | 7 +- .../MyHttpTrigger.cs | 2 + .../Program.cs | 8 +- .../Program.cs | 44 +++++- .../AzureStorageEndToEnd.AppHost/Program.cs | 4 +- .../Aspire.Hosting.Azure.Storage.csproj | 4 + .../AzureQueueStorageQueueResource.cs | 56 ++++++++ .../AzureQueueStorageResource.cs | 27 ++++ .../AzureStorageExtensions.cs | 126 ++++++++++++++++-- .../AzureStorageResource.cs | 1 + ...StorageExtensions.StorageQueueComponent.cs | 42 +++--- ...torageExtensions.StorageQueuesComponent.cs | 68 ++++++++++ .../AspireQueueStorageExtensions.cs | 67 +++++++++- .../AssemblyInfo.cs | 3 + .../AzureStorageQueueSettings.cs | 35 +++++ .../AzureStorageQueuesSettings.cs | 2 +- .../AzureStorageQueueSettingsTests.cs | 52 ++++++++ .../ConformanceTests.cs | 3 + .../Aspire.Hosting.Azure.Tests.csproj | 1 + .../AzureStorageEmulatorFunctionalTests.cs | 85 +++++++++++- .../AzureStorageExtensionsTests.cs | 107 ++++++++++++++- ...dAzureStorageViaPublishMode.verified.bicep | 5 - ...AccessOverridesDefaultFalse.verified.bicep | 5 - ...s.AddAzureStorageViaRunMode.verified.bicep | 5 - ...AccessOverridesDefaultFalse.verified.bicep | 5 - ...tsCorrectly_WithSnapshot#01.verified.bicep | 5 - ...sts.ResourceNamesBicepValid.verified.bicep | 9 ++ ...ageAccountWithResourceGroup.verified.bicep | 5 - ...urceGroupAndStaticArguments.verified.bicep | 5 - .../ProjectSpecificTests.cs | 1 + 33 files changed, 735 insertions(+), 109 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.Storage/AzureQueueStorageQueueResource.cs create mode 100644 src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueuesComponent.cs create mode 100644 src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs create mode 100644 tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs index 00afe533fb1..d136b9fb68e 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.ApiService/Program.cs @@ -14,8 +14,8 @@ // Add service defaults & Aspire client integrations. builder.AddServiceDefaults(); -builder.AddAzureQueueClient("queue"); -builder.AddAzureBlobClient("blob"); +builder.AddAzureQueueClient("queues"); +builder.AddAzureBlobClient("blobs"); builder.AddAzureEventHubProducerClient("myhub"); #if !SKIP_UNSTABLE_EMULATORS builder.AddAzureServiceBusClient("messaging"); @@ -24,10 +24,16 @@ var app = builder.Build(); +app.MapGet("/", async (HttpClient client) => +{ + var stream = await client.GetStreamAsync("http://funcapp/api/injected-resources"); + return Results.Stream(stream, "application/json"); +}); + app.MapGet("/publish/asq", async (QueueServiceClient client, CancellationToken cancellationToken) => { - var queue = client.GetQueueClient("queue"); - await queue.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + var queue = client.GetQueueClient("myqueue1"); + var data = Convert.ToBase64String(Encoding.UTF8.GetBytes("Hello, World!")); await queue.SendMessageAsync(data, cancellationToken: cancellationToken); return Results.Ok("Message sent to Azure Storage Queue."); @@ -41,15 +47,14 @@ static string RandomString(int length) app.MapGet("/publish/blob", async (BlobServiceClient client, CancellationToken cancellationToken, int length = 20) => { - var container = client.GetBlobContainerClient("blobs"); - await container.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + var container = client.GetBlobContainerClient("myblobcontainer"); var entry = new { Id = Guid.NewGuid(), Text = RandomString(length) }; var blob = container.GetBlobClient(entry.Id.ToString()); await blob.UploadAsync(new BinaryData(entry)); - return Results.Ok("String uploaded to Azure Storage Blobs."); + return Results.Ok($"String uploaded to Azure Storage Blobs {container.Uri}."); }); app.MapGet("/publish/eventhubs", async (EventHubProducerClient client, CancellationToken cancellationToken, int length = 20) => @@ -80,12 +85,6 @@ static string RandomString(int length) }); #endif -app.MapGet("/", async (HttpClient client) => -{ - var stream = await client.GetStreamAsync("http://funcapp/api/injected-resources"); - return Results.Stream(stream, "application/json"); -}); - app.MapDefaultEndpoints(); app.Run(); diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs index e52a06b7427..14e38800b6a 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs @@ -1,9 +1,12 @@ var builder = DistributedApplication.CreateBuilder(args); var storage = builder.AddAzureStorage("storage").RunAsEmulator(); -var queue = storage.AddQueues("queue"); -var blob = storage.AddBlobs("blob"); -var myBlobContainer = blob.AddBlobContainer("myblobcontainer"); + +var queues = storage.AddQueues("queues"); +var myQueue = queues.AddQueue("myqueue1"); + +var blobs = storage.AddBlobs("blobs"); +var myBlobContainer = blobs.AddBlobContainer("myblobcontainer"); var eventHub = builder.AddAzureEventHubs("eventhubs") .RunAsEmulator() @@ -22,13 +25,14 @@ var funcApp = builder.AddAzureFunctionsProject("funcapp") .WithExternalHttpEndpoints() .WithReference(eventHub).WaitFor(eventHub) - .WithReference(myBlobContainer).WaitFor(myBlobContainer) #if !SKIP_UNSTABLE_EMULATORS .WithReference(serviceBus).WaitFor(serviceBus) .WithReference(cosmosDb).WaitFor(cosmosDb) #endif - .WithReference(blob) - .WithReference(queue); + .WithReference(blobs) + .WithReference(myBlobContainer).WaitFor(myBlobContainer) + .WithReference(queues) + .WithReference(myQueue).WaitFor(myQueue); builder.AddProject("apiservice") .WithReference(eventHub).WaitFor(eventHub) @@ -36,8 +40,8 @@ .WithReference(serviceBus).WaitFor(serviceBus) .WithReference(cosmosDb).WaitFor(cosmosDb) #endif - .WithReference(queue) - .WithReference(blob) + .WithReference(queues) + .WithReference(blobs) .WithReference(funcApp); builder.Build().Run(); diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs index 6c535261cdd..845272ba6a2 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs @@ -4,16 +4,16 @@ namespace AzureFunctionsEndToEnd.Functions; -public class MyAzureBlobTrigger(ILogger logger, BlobContainerClient containerClient) +public class MyAzureBlobTrigger(BlobContainerClient containerClient, ILogger logger) { [Function(nameof(MyAzureBlobTrigger))] - [BlobOutput("test-files/{name}.txt", Connection = "blob")] - public async Task RunAsync([BlobTrigger("blobs/{name}", Connection = "blob")] string triggerString, FunctionContext context) + [BlobOutput("test-files/{name}.txt", Connection = "blobs")] + public async Task RunAsync([BlobTrigger("myblobcontainer/{name}", Connection = "blobs")] string triggerString, FunctionContext context) { var blobName = (string)context.BindingContext.BindingData["name"]!; - await containerClient.UploadBlobAsync(blobName, new BinaryData(triggerString)); + _ = await containerClient.GetAccountInfoAsync(); - logger.LogInformation("C# blob trigger function invoked for 'blobs/{source}' with {message}...", blobName, triggerString); + logger.LogInformation("C# blob trigger function invoked for 'myblobcontainer/{source}' with {message}...", blobName, triggerString); return triggerString.ToUpper(); } } diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs index 0ea035d98c5..035b7d19f5b 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureQueueTrigger.cs @@ -1,14 +1,17 @@ +using Azure.Storage.Queues; using Azure.Storage.Queues.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; namespace AzureFunctionsEndToEnd.Functions; -public class MyAzureQueueTrigger(ILogger logger) +public class MyAzureQueueTrigger(QueueClient queueClient, ILogger logger) { [Function(nameof(MyAzureQueueTrigger))] - public void Run([QueueTrigger("queue", Connection = "queue")] QueueMessage message) + public void Run([QueueTrigger("myqueue1", Connection = "queues")] QueueMessage message) { + _ = queueClient.GetProperties(); + logger.LogInformation("C# Queue trigger function processed: {Text}", message.MessageText); } } diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs index 83fc3a38893..ea0e99833e1 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs @@ -22,6 +22,7 @@ public class MyHttpTrigger( #endif EventHubProducerClient eventHubProducerClient, QueueServiceClient queueServiceClient, + QueueClient queueClient, BlobServiceClient blobServiceClient, BlobContainerClient blobContainerClient) { @@ -35,6 +36,7 @@ public IResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] Ht #endif stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Aspire-injected EventHubProducerClient namespace: {eventHubProducerClient.FullyQualifiedNamespace}"); stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Aspire-injected QueueServiceClient URI: {queueServiceClient.Uri}"); + stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Aspire-injected QueueClient URI: {queueClient.Uri}"); stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Aspire-injected BlobServiceClient URI: {blobServiceClient.Uri}"); stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Aspire-injected BlobContainerClient URI: {blobContainerClient.Uri}"); return Results.Text(stringBuilder.ToString()); diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs index 336749046b4..0dcab6eb03b 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs @@ -4,9 +4,13 @@ var builder = FunctionsApplication.CreateBuilder(args); builder.AddServiceDefaults(); -builder.AddAzureQueueClient("queue"); -builder.AddAzureBlobClient("blob"); + +builder.AddAzureQueueClient("queues"); +builder.AddAzureQueue("myqueue1"); + +builder.AddAzureBlobClient("blobs"); builder.AddAzureBlobContainerClient("myblobcontainer"); + builder.AddAzureEventHubProducerClient("myhub"); #if !SKIP_UNSTABLE_EMULATORS builder.AddAzureServiceBusClient("messaging"); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 02f16b8f516..dc4353ac7ba 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -12,31 +12,61 @@ builder.AddKeyedAzureBlobContainerClient("foocontainer"); builder.AddAzureQueueClient("queues"); +builder.AddKeyedAzureQueue("myqueue"); var app = builder.Build(); app.MapDefaultEndpoints(); -app.MapGet("/", async (BlobServiceClient bsc, QueueServiceClient qsc, [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClient1) => +app.MapGet("/", (HttpContext context) => +{ + var request = context.Request; + var scheme = request.Scheme; + var host = request.Host; + + var endpointDataSource = context.RequestServices.GetRequiredService(); + var urls = endpointDataSource.Endpoints + .OfType() + .Select(e => $"{scheme}://{host}{e.RoutePattern.RawText}"); + + var html = "
    " + + string.Join("", urls.Select(url => $"
  • {url}
  • ")) + + "
"; + + context.Response.ContentType = "text/html"; + return context.Response.WriteAsync(html); +}); + +app.MapGet("/blobs", async (BlobServiceClient bsc, [FromKeyedServices("foocontainer")] BlobContainerClient bcc) => { var blobNames = new List(); var blobNameAndContent = Guid.NewGuid().ToString(); - await keyedContainerClient1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + await bcc.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); var directContainerClient = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); await directContainerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); await ReadBlobsAsync(directContainerClient, blobNames); - await ReadBlobsAsync(keyedContainerClient1, blobNames); - - var queue = qsc.GetQueueClient("myqueue"); - await queue.CreateIfNotExistsAsync(); - await queue.SendMessageAsync("Hello, world!"); + await ReadBlobsAsync(bcc, blobNames); return blobNames; }); +app.MapGet("/queues", async (QueueServiceClient qsc, [FromKeyedServices("myqueue")] QueueClient qc) => +{ + const string text = "Hello, World!"; + List messages = [$"Sent: {text}"]; + + var queue = qsc.GetQueueClient("my-queue"); + await queue.SendMessageAsync(text); + + var msg = await qc.ReceiveMessageAsync(); + messages.Add($"Received: {msg.Value.Body}"); + + return messages; +}); + app.Run(); static async Task ReadBlobsAsync(BlobContainerClient containerClient, List output) diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index aba1f9e6eea..138c03e87be 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -13,6 +13,7 @@ blobs.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); var queues = storage.AddQueues("queues"); +var myqueue = queues.AddQueue("myqueue", queueName: "my-queue"); var storage2 = builder.AddAzureStorage("storage2").RunAsEmulator(container => { @@ -25,7 +26,8 @@ .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) .WithReference(blobContainer2).WaitFor(blobContainer2) - .WithReference(queues).WaitFor(queues); + .WithReference(queues).WaitFor(queues) + .WithReference(myqueue).WaitFor(myqueue); #if !SKIP_DASHBOARD_REFERENCE // This project is only added in playground projects to support development/debugging diff --git a/src/Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.Storage.csproj b/src/Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.Storage.csproj index f3a8aced451..67cc268a09e 100644 --- a/src/Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.Storage.csproj +++ b/src/Aspire.Hosting.Azure.Storage/Aspire.Hosting.Azure.Storage.csproj @@ -14,7 +14,11 @@ + + + + diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageQueueResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageQueueResource.cs new file mode 100644 index 00000000000..dd05fe88413 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageQueueResource.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Azure.Provisioning; + +namespace Aspire.Hosting; + +/// +/// A resource that represents an Azure Storage queue. +/// +/// The name of the resource. +/// The name of the queue. +/// The that the resource is stored in. +public class AzureQueueStorageQueueResource(string name, string queueName, AzureQueueStorageResource parent) : Resource(name), + IResourceWithConnectionString, + IResourceWithParent +{ + /// + /// Gets the queue name. + /// + public string QueueName { get; } = ThrowIfNullOrEmpty(queueName); + + /// + /// Gets the connection string template for the manifest for the Azure Storage queue resource. + /// + public ReferenceExpression ConnectionStringExpression => Parent.GetConnectionString(QueueName); + + /// + /// Gets the parent of this . + /// + public AzureQueueStorageResource Parent => parent ?? throw new ArgumentNullException(nameof(parent)); + + /// + /// Converts the current instance to a provisioning entity. + /// + /// A instance. + internal global::Azure.Provisioning.Storage.StorageQueue ToProvisioningEntity() + { + global::Azure.Provisioning.Storage.StorageQueue queue = new(Infrastructure.NormalizeBicepIdentifier(Name)) + { + Name = QueueName + }; + + return queue; + } + + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(argument, paramName); + return argument; + } +} diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs index 295a5b5a77b..9376e452d03 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; +using Azure.Provisioning; namespace Aspire.Hosting.Azure; @@ -15,6 +16,10 @@ public class AzureQueueStorageResource(string name, AzureStorageResource storage IResourceWithParent, IResourceWithAzureFunctionsConfig { + // NOTE: if ever these contants are changed, the AzureStorageQueueSettings in Aspire.Azure.Storage.Queues class should be updated as well. + private const string Endpoint = nameof(Endpoint); + private const string QueueName = nameof(QueueName); + /// /// Gets the parent AzureStorageResource of this AzureQueueStorageResource. /// @@ -26,6 +31,18 @@ public class AzureQueueStorageResource(string name, AzureStorageResource storage public ReferenceExpression ConnectionStringExpression => Parent.GetQueueConnectionString(); + internal ReferenceExpression GetConnectionString(string? queueName) + { + if (string.IsNullOrEmpty(queueName)) + { + return ConnectionStringExpression; + } + + ReferenceExpressionBuilder builder = new(); + builder.Append($"{Endpoint}=\"{ConnectionStringExpression}\";{QueueName}={queueName};"); + return builder.Build(); + } + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) { if (Parent.IsEmulator) @@ -42,4 +59,14 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction target[$"{AzureStorageResource.QueuesConnectionKeyPrefix}__{connectionName}__ServiceUri"] = Parent.QueueEndpoint; } } + + /// + /// Converts the current instance to a provisioning entity. + /// + /// A instance. + internal global::Azure.Provisioning.Storage.QueueService ToProvisioningEntity() + { + global::Azure.Provisioning.Storage.QueueService service = new(Infrastructure.NormalizeBicepIdentifier(Name)); + return service; + } } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index bb3213a91a8..927be7d95d1 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -8,6 +8,7 @@ using Azure.Provisioning; using Azure.Provisioning.Storage; using Azure.Storage.Blobs; +using Azure.Storage.Queues; using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -71,25 +72,43 @@ public static IResourceBuilder AddAzureStorage(this IDistr Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } } }); - var blobs = new BlobService("blobs") - { - Parent = storageAccount - }; - infrastructure.Add(blobs); + var azureResource = (AzureStorageResource)infrastructure.AspireResource; - infrastructure.Add(new ProvisioningOutput("blobEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.BlobUri }); - infrastructure.Add(new ProvisioningOutput("queueEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.QueueUri }); - infrastructure.Add(new ProvisioningOutput("tableEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.TableUri }); + if (azureResource.BlobContainers.Count > 0) + { + var blobs = new BlobService("blobs") + { + Parent = storageAccount + }; + infrastructure.Add(blobs); - var azureResource = (AzureStorageResource)infrastructure.AspireResource; + foreach (var blobContainer in azureResource.BlobContainers) + { + var cdkBlobContainer = blobContainer.ToProvisioningEntity(); + cdkBlobContainer.Parent = blobs; + infrastructure.Add(cdkBlobContainer); + } + } - foreach (var blobContainer in azureResource.BlobContainers) + if (azureResource.Queues.Count > 0) { - var cdkBlobContainer = blobContainer.ToProvisioningEntity(); - cdkBlobContainer.Parent = blobs; - infrastructure.Add(cdkBlobContainer); + var queues = new QueueService("queues") + { + Parent = storageAccount + }; + infrastructure.Add(queues); + foreach (var queue in azureResource.Queues) + { + var cdkQueue = queue.ToProvisioningEntity(); + cdkQueue.Parent = queues; + infrastructure.Add(cdkQueue); + } } + infrastructure.Add(new ProvisioningOutput("blobEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.BlobUri }); + infrastructure.Add(new ProvisioningOutput("queueEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.QueueUri }); + infrastructure.Add(new ProvisioningOutput("tableEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.TableUri }); + // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = storageAccount.Name }); }; @@ -132,6 +151,7 @@ public static IResourceBuilder RunAsEmulator(this IResourc }); BlobServiceClient? blobServiceClient = null; + QueueServiceClient? queueServiceClient = null; builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { @@ -142,6 +162,10 @@ public static IResourceBuilder RunAsEmulator(this IResourc var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null."); blobServiceClient = CreateBlobServiceClient(connectionString); + + connectionString = await builder.Resource.GetQueueConnectionString().GetValueAsync(ct).ConfigureAwait(false) + ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null."); + queueServiceClient = CreateQueueServiceClient(connectionString); }); builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => @@ -150,12 +174,19 @@ public static IResourceBuilder RunAsEmulator(this IResourc // This means we can safely use this event to create the blob containers. _ = blobServiceClient ?? throw new InvalidOperationException($"{nameof(BlobServiceClient)} is not initialized."); + _ = queueServiceClient ?? throw new InvalidOperationException($"{nameof(QueueServiceClient)} is not initialized."); foreach (var container in builder.Resource.BlobContainers) { var blobContainerClient = blobServiceClient.GetBlobContainerClient(container.BlobContainerName); await blobContainerClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); } + + foreach (var queue in builder.Resource.Queues) + { + var queueClient = queueServiceClient.GetQueueClient(queue.QueueName); + await queueClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); + } }); // Add the "Storage" resource health check. There will be separate health checks for the nested child resources. @@ -378,7 +409,62 @@ public static IResourceBuilder AddQueues(this IResour ArgumentException.ThrowIfNullOrEmpty(name); var resource = new AzureQueueStorageResource(name, builder.Resource); - return builder.ApplicationBuilder.AddResource(resource); + + string? connectionString = null; + builder.ApplicationBuilder.Eventing.Subscribe(resource, async (@event, ct) => + { + connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + }); + + // Add the "Queues" resource health check. This is a separate health check from the "Storage" resource health check. + // Doing it on the storage is not sufficient as the WaitForHealthyAsync doesn't bubble up to the parent resources. + var healthCheckKey = $"{resource.Name}_check"; + + QueueServiceClient? queueServiceClient = null; + builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureQueueStorage(sp => + { + return queueServiceClient ??= CreateQueueServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized.")); + }, name: healthCheckKey); + + return builder.ApplicationBuilder + .AddResource(resource) + .WithHealthCheck(healthCheckKey); + } + + /// + /// Creates a builder for the which can be referenced to get the Azure Storage queue endpoint for the storage account. + /// + /// The for . + /// The name of the resource. + /// The name of the queue. + /// An for the . + public static IResourceBuilder AddQueue(this IResourceBuilder builder, [ResourceName] string name, string? queueName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + queueName ??= name; + + AzureQueueStorageQueueResource resource = new(name, queueName, builder.Resource); + builder.Resource.Parent.Queues.Add(resource); + + string? connectionString = null; + builder.ApplicationBuilder.Eventing.Subscribe(resource, async (@event, ct) => + { + connectionString = await resource.Parent.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false); + }); + + var healthCheckKey = $"{resource.Name}_check"; + + QueueServiceClient? queueServiceClient = null; + builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureQueueStorage( + sp => queueServiceClient ??= CreateQueueServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized.")), + optionsFactory: sp => new HealthChecks.Azure.Storage.Queues.AzureQueueStorageHealthCheckOptions { QueueName = queueName }, + name: healthCheckKey); + + return builder.ApplicationBuilder + .AddResource(resource) + .WithHealthCheck(healthCheckKey); } private static BlobServiceClient CreateBlobServiceClient(string connectionString) @@ -393,6 +479,18 @@ private static BlobServiceClient CreateBlobServiceClient(string connectionString } } + static QueueServiceClient CreateQueueServiceClient(string connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + return new QueueServiceClient(uri, new DefaultAzureCredential()); + } + else + { + return new QueueServiceClient(connectionString); + } + } + /// /// Assigns the specified roles to the given resource, granting it the necessary permissions /// on the target Azure Storage account. This replaces the default role assignments for the resource. diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs index e5f581a1a48..bd87fe18c25 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs @@ -24,6 +24,7 @@ public class AzureStorageResource(string name, Action new(this, "table"); internal List BlobContainers { get; } = []; + internal List Queues { get; } = []; /// /// Gets the "blobEndpoint" output reference from the bicep template for the Azure Storage resource. diff --git a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs index ddbb7cc8263..e1cea0b14d6 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueueComponent.cs @@ -6,6 +6,7 @@ using Azure.Core; using Azure.Core.Extensions; using Azure.Storage.Queues; +using Azure.Storage.Queues.Specialized; using HealthChecks.Azure.Storage.Queues; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; @@ -16,53 +17,58 @@ namespace Microsoft.Extensions.Hosting; public static partial class AspireQueueStorageExtensions { - private sealed class StorageQueueComponent : AzureComponent + private sealed partial class StorageQueueComponent : AzureComponent { - protected override IAzureClientBuilder AddClient( - AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageQueuesSettings settings, string connectionName, - string configurationSectionName) + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageQueueSettings settings, string connectionName, string configurationSectionName) { - return ((IAzureClientFactoryBuilderWithCredential)azureFactoryBuilder).RegisterClientFactory((options, cred) => + return ((IAzureClientFactoryBuilderWithCredential)azureFactoryBuilder).RegisterClientFactory((options, cred) => { + if (string.IsNullOrEmpty(settings.QueueName)) + { + throw new InvalidOperationException($"The connection string '{connectionName}' does not exist or is missing the queue name."); + } + var connectionString = settings.ConnectionString; if (string.IsNullOrEmpty(connectionString) && settings.ServiceUri is null) { throw new InvalidOperationException($"A QueueServiceClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'ServiceUri' in the '{configurationSectionName}' configuration section."); } - return !string.IsNullOrEmpty(connectionString) - ? new QueueServiceClient(connectionString, options) - : cred is not null - ? new QueueServiceClient(settings.ServiceUri, cred, options) - : new QueueServiceClient(settings.ServiceUri, options); + var queueServiceClient = !string.IsNullOrEmpty(connectionString) ? new QueueServiceClient(connectionString, options) : + cred is not null ? new QueueServiceClient(settings.ServiceUri, cred, options) : + new QueueServiceClient(settings.ServiceUri, options); + + var client = queueServiceClient.GetQueueClient(settings.QueueName); + return client; }, requiresCredential: false); } - protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) { #pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works clientBuilder.ConfigureOptions(options => configuration.Bind(options)); #pragma warning restore IDE0200 } - protected override void BindSettingsToConfiguration(AzureStorageQueuesSettings settings, IConfiguration configuration) + protected override void BindSettingsToConfiguration(AzureStorageQueueSettings settings, IConfiguration configuration) { configuration.Bind(settings); } - protected override IHealthCheck CreateHealthCheck(QueueServiceClient client, AzureStorageQueuesSettings settings) - => new AzureQueueStorageHealthCheck(client, new AzureQueueStorageHealthCheckOptions()); + protected override IHealthCheck CreateHealthCheck(QueueClient client, AzureStorageQueueSettings settings) + => new AzureQueueStorageHealthCheck(client.GetParentQueueServiceClient(), new AzureQueueStorageHealthCheckOptions { QueueName = client.Name }); - protected override bool GetHealthCheckEnabled(AzureStorageQueuesSettings settings) + protected override bool GetHealthCheckEnabled(AzureStorageQueueSettings settings) => !settings.DisableHealthChecks; - protected override TokenCredential? GetTokenCredential(AzureStorageQueuesSettings settings) + protected override TokenCredential? GetTokenCredential(AzureStorageQueueSettings settings) => settings.Credential; - protected override bool GetMetricsEnabled(AzureStorageQueuesSettings settings) + protected override bool GetMetricsEnabled(AzureStorageQueueSettings settings) => false; - protected override bool GetTracingEnabled(AzureStorageQueuesSettings settings) + protected override bool GetTracingEnabled(AzureStorageQueueSettings settings) => !settings.DisableTracing; } } diff --git a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueuesComponent.cs b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueuesComponent.cs new file mode 100644 index 00000000000..6428f0eafd6 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.StorageQueuesComponent.cs @@ -0,0 +1,68 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Aspire.Azure.Storage.Queues; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Storage.Queues; +using HealthChecks.Azure.Storage.Queues; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +partial class AspireQueueStorageExtensions +{ + private sealed class StorageQueuesComponent : AzureComponent + { + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageQueuesSettings settings, string connectionName, + string configurationSectionName) + { + return ((IAzureClientFactoryBuilderWithCredential)azureFactoryBuilder).RegisterClientFactory((options, cred) => + { + var connectionString = settings.ConnectionString; + if (string.IsNullOrEmpty(connectionString) && settings.ServiceUri is null) + { + throw new InvalidOperationException($"A QueueServiceClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'ServiceUri' in the '{configurationSectionName}' configuration section."); + } + + return !string.IsNullOrEmpty(connectionString) + ? new QueueServiceClient(connectionString, options) + : cred is not null + ? new QueueServiceClient(settings.ServiceUri, cred, options) + : new QueueServiceClient(settings.ServiceUri, options); + }, requiresCredential: false); + } + + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) + { +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 + } + + protected override void BindSettingsToConfiguration(AzureStorageQueuesSettings settings, IConfiguration configuration) + { + configuration.Bind(settings); + } + + protected override IHealthCheck CreateHealthCheck(QueueServiceClient client, AzureStorageQueuesSettings settings) + => new AzureQueueStorageHealthCheck(client, new AzureQueueStorageHealthCheckOptions()); + + protected override bool GetHealthCheckEnabled(AzureStorageQueuesSettings settings) + => !settings.DisableHealthChecks; + + protected override TokenCredential? GetTokenCredential(AzureStorageQueuesSettings settings) + => settings.Credential; + + protected override bool GetMetricsEnabled(AzureStorageQueuesSettings settings) + => false; + + protected override bool GetTracingEnabled(AzureStorageQueuesSettings settings) + => !settings.DisableTracing; + } +} diff --git a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs index 8fdcf71715e..5b0c0ca756c 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AspireQueueStorageExtensions.cs @@ -42,7 +42,7 @@ public static void AddAzureQueueClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(connectionName); - new StorageQueueComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + new StorageQueuesComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); } /// @@ -75,6 +75,71 @@ public static void AddKeyedAzureQueueClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); + new StorageQueuesComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + } + + /// + /// Registers as a singleton in the services provided by the . + /// Enables retries, corresponding health check, logging and telemetry. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// + /// An optional method that can be used for customizing the . + /// It's invoked after the settings are read from the configuration. + /// + /// + /// An optional method that can be used for customizing the . + /// + /// Reads the configuration from "Aspire:Azure:Storage:Queues:{name}" section. + /// + /// Neither nor is provided. + /// - or - + /// is not provided in the configuration section. + /// + public static void AddAzureQueue( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + new StorageQueueComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + } + + /// + /// Registers as a singleton in the services provided by the . + /// Enables retries, corresponding health check, logging and telemetry. + /// + /// The to read config from and add services to. + /// + /// The name of the component, which is used as the of the service and also to retrieve + /// the connection string from the ConnectionStrings configuration section. + /// + /// + /// An optional method that can be used for customizing the . + /// It's invoked after the settings are read from the configuration. + /// + /// + /// An optional method that can be used for customizing the . + /// + /// Reads the configuration from "Aspire:Azure:Storage:Queues:{name}" section. + /// + /// Neither nor is provided. + /// - or - + /// is not provided in the configuration section. + /// + public static void AddKeyedAzureQueue( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + new StorageQueueComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); } } diff --git a/src/Components/Aspire.Azure.Storage.Queues/AssemblyInfo.cs b/src/Components/Aspire.Azure.Storage.Queues/AssemblyInfo.cs index 8b05276196f..0003ad5f726 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AssemblyInfo.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AssemblyInfo.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using Aspire; using Aspire.Azure.Storage.Queues; using Azure.Storage.Queues; @@ -12,3 +13,5 @@ "Azure", "Azure.Core", "Azure.Identity")] + +[assembly: InternalsVisibleTo("Aspire.Azure.Storage.Queues.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001004b86c4cb78549b34bab61a3b1800e23bfeb5b3ec390074041536a7e3cbd97f5f04cf0f857155a8928eaa29ebfd11cfbbad3ba70efea7bda3226c6a8d370a4cd303f714486b6ebc225985a638471e6ef571cc92a4613c00b8fa65d61ccee0cbe5f36330c9a01f4183559f1bef24cc2917c6d913e3a541333a1d05d9bed22b38cb")] diff --git a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs new file mode 100644 index 00000000000..c7d5e35c9b7 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data.Common; +using Aspire.Azure.Common; + +namespace Aspire.Azure.Storage.Queues; + +/// +/// Provides the client configuration settings for connecting to Azure Storage queue. +/// +public sealed class AzureStorageQueueSettings : AzureStorageQueuesSettings, IConnectionStringSettings +{ + /// + /// Gets or sets the name of the blob container. + /// + public string? QueueName { get; set; } + + void IConnectionStringSettings.ParseConnectionString(string? connectionString) + { + if (string.IsNullOrEmpty(connectionString)) + { + return; + } + + DbConnectionStringBuilder builder = new() { ConnectionString = connectionString }; + + // NOTE: if ever these contants are changed, the AzureQueueStorageResource in Aspire.Hosting.Azure.Storage class should be updated as well. + if (builder.TryGetValue("Endpoint", out var endpoint) && builder.TryGetValue("QueueName", out var queueName)) + { + ConnectionString = endpoint.ToString(); + QueueName = queueName.ToString(); + } + } +} diff --git a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs index acedbf8b329..dc780c28748 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs @@ -9,7 +9,7 @@ namespace Aspire.Azure.Storage.Queues; /// /// Provides the client configuration settings for connecting to Azure Storage Queues. /// -public sealed class AzureStorageQueuesSettings : IConnectionStringSettings +public class AzureStorageQueuesSettings : IConnectionStringSettings { /// /// Gets or sets the connection string used to connect to the blob service. diff --git a/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs b/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs new file mode 100644 index 00000000000..037e60adf44 --- /dev/null +++ b/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Aspire.Azure.Storage.Queues; +using Xunit; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureStorageQueueSettingsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(";")] + [InlineData("Endpoint=https://example.queues.core.windows.net;")] + [InlineData("QueueName=my-queue;")] + [InlineData("Endpoint=https://example.queueName.core.windows.net;ExtraParam=value;")] + public void ParseConnectionString_invalid_input(string? connectionString) + { + var settings = new AzureStorageQueueSettings(); + + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + + Assert.Null(settings.ConnectionString); + Assert.Null(settings.QueueName); + } + + [Fact] + public void ParseConnectionString_invalid_input_results_in_AE() + { + var settings = new AzureStorageQueueSettings(); + string connectionString = "InvalidConnectionString"; + + Assert.Throws(() => ((IConnectionStringSettings)settings).ParseConnectionString(connectionString)); + } + + [Theory] + [InlineData("Endpoint=https://example.queueName.core.windows.net;QueueName=my-queue")] + [InlineData("Endpoint=https://example.queueName.core.windows.net;QueueName=my-queue;ExtraParam=value")] + [InlineData("endpoint=https://example.queueName.core.windows.net;queuename=my-queue")] + [InlineData("ENDPOINT=https://example.queueName.core.windows.net;QUEUENAME=my-queue")] + public void ParseConnectionString_valid_input(string connectionString) + { + var settings = new AzureStorageQueueSettings(); + + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + + Assert.Equal("https://example.queueName.core.windows.net", settings.ConnectionString); + Assert.Equal("my-queue", settings.QueueName); + } +} diff --git a/tests/Aspire.Azure.Storage.Queues.Tests/ConformanceTests.cs b/tests/Aspire.Azure.Storage.Queues.Tests/ConformanceTests.cs index 006803a8083..26d65b29196 100644 --- a/tests/Aspire.Azure.Storage.Queues.Tests/ConformanceTests.cs +++ b/tests/Aspire.Azure.Storage.Queues.Tests/ConformanceTests.cs @@ -24,6 +24,9 @@ public class ConformanceTests : ConformanceTests "Azure.Storage.Queues.QueueClient"; + // AzureStorageQueuesSettings subclassed by AzureStorageQueueSettings + protected override bool CheckOptionClassSealed => false; + protected override string[] RequiredLogCategories => new string[] { "Azure.Core", diff --git a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj index 61d88b75159..190b127ff6a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj +++ b/tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs index df14ddaaa3a..bda7fee6692 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs @@ -1,10 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Utils; +using Aspire.TestUtilities; using Azure.Storage.Blobs; +using Azure.Storage.Queues; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; @@ -106,6 +107,50 @@ public async Task VerifyWaitForOnAzureStorageEmulatorForBlobContainersBlocksDepe await app.StopAsync(); } + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnAzureStorageEmulatorForQueueBlocksDependentResources() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var healthCheckTcs = new TaskCompletionSource(); + builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => + { + return healthCheckTcs.Task; + }); + + var storage = builder.AddAzureStorage("resource") + .RunAsEmulator() + .WithHealthCheck("blocking_check"); + + var queues = storage.AddQueues("queues"); + var testQueue = queues.AddQueue("testqueue"); + + var dependentResource = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WaitFor(testQueue); + + using var app = builder.Build(); + + var pendingStart = app.StartAsync(cts.Token); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceAsync(storage.Resource.Name, KnownResourceStates.Running, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); + + healthCheckTcs.SetResult(HealthCheckResult.Healthy()); + + await rns.WaitForResourceHealthyAsync(testQueue.Resource.Name, cts.Token); + + await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); + + await pendingStart; + + await app.StopAsync(); + } + [Fact] [RequiresDocker] public async Task VerifyAzureStorageEmulatorResource() @@ -179,4 +224,42 @@ public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created() var downloadResult = (await blobClient.DownloadContentAsync()).Value; Assert.Equal(blobNameAndContent, downloadResult.Content.ToString()); } + + [Fact] + [RequiresDocker] + public async Task VerifyAzureStorageEmulator_queue_auto_created() + { + var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + var queues = storage.AddQueues("queues"); + var queue = queues.AddQueue("testqueue"); + + using var app = builder.Build(); + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceHealthyAsync(queue.Resource.Name, cancellationToken: cts.Token); + + var hb = Host.CreateApplicationBuilder(); + hb.Configuration["ConnectionStrings:QueueConnection"] = await queues.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + hb.AddAzureQueueClient("QueueConnection"); + + using var host = hb.Build(); + await host.StartAsync(); + + var serviceClient = host.Services.GetRequiredService(); + var queueClient = serviceClient.GetQueueClient("testqueue"); + + var exists = await queueClient.ExistsAsync(); + Assert.True(exists, "Queue should exist after starting the application."); + + var blobNameAndContent = Guid.NewGuid().ToString(); + var response = await queueClient.SendMessageAsync(blobNameAndContent); + + var peekMessage = await queueClient.PeekMessageAsync(); + + Assert.Equal(blobNameAndContent, peekMessage.Value.Body.ToString()); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index bc0b1a97037..6037ef3f0f1 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -271,6 +271,111 @@ public void AddBlobContainer_ConnectionString_unresolved_expected() Assert.Equal("Endpoint={storage.outputs.blobEndpoint};ContainerName=myContainer", blobContainer.Resource.ConnectionStringExpression.ValueExpression); } + [Fact] + public async Task AddQueues_ConnectionString_resolved_expected_RunAsEmulator() + { + const string expected = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var storage = builder.AddAzureStorage("storage").RunAsEmulator(e => + { + e.WithEndpoint("blob", e => e.AllocatedEndpoint = new(e, "localhost", 10000)); + e.WithEndpoint("queue", e => e.AllocatedEndpoint = new(e, "localhost", 10001)); + e.WithEndpoint("table", e => e.AllocatedEndpoint = new(e, "localhost", 10002)); + }); + + Assert.True(storage.Resource.IsContainer()); + + var queues = storage.AddQueues("queues"); + + Assert.Equal(expected, await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync()); + } + + [Fact] + public async Task AddQueues_ConnectionString_resolved_expected() + { + const string connectionString = "https://myblob"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var storagesku = builder.AddParameter("storagesku"); + var storage = builder.AddAzureStorage("storage"); + storage.Resource.Outputs["queueEndpoint"] = connectionString; + + var queues = storage.AddQueues("queues"); + + Assert.Equal(connectionString, await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync()); + } + + [Fact] + public void AddQueues_ConnectionString_unresolved_expected() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + + Assert.Equal("{storage.outputs.queueEndpoint}", queues.Resource.ConnectionStringExpression.ValueExpression); + } + + [Fact] + public async Task AddQueue_ConnectionString_resolved_expected_RunAsEmulator() + { + const string queueName = "my-queue"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var storage = builder.AddAzureStorage("storage").RunAsEmulator(e => + { + e.WithEndpoint("blob", e => e.AllocatedEndpoint = new(e, "localhost", 10000)); + e.WithEndpoint("queue", e => e.AllocatedEndpoint = new(e, "localhost", 10001)); + e.WithEndpoint("table", e => e.AllocatedEndpoint = new(e, "localhost", 10002)); + }); + + Assert.True(storage.Resource.IsContainer()); + + var queues = storage.AddQueues("queues"); + var queue = queues.AddQueue(name: "myqueue", queueName); + + string? blobConntionString = await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync(); + string expected = $"Endpoint=\"{blobConntionString}\";QueueName={queueName};"; + + Assert.Equal(expected, await ((IResourceWithConnectionString)queue.Resource).GetConnectionStringAsync()); + } + + [Fact] + public async Task AddQueue_ConnectionString_resolved_expected() + { + const string queueName = "my-queue"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var storagesku = builder.AddParameter("storagesku"); + var storage = builder.AddAzureStorage("storage"); + storage.Resource.Outputs["queueEndpoint"] = "https://myblob"; + + var queues = storage.AddQueues("queues"); + var queue = queues.AddQueue(name: "myqueue", queueName); + + string? connectionString = await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync(); + string expected = $"Endpoint=\"{connectionString}\";QueueName={queueName};"; + + Assert.Equal(expected, await ((IResourceWithConnectionString)queue.Resource).GetConnectionStringAsync()); + } + + [Fact] + public void AddQueue_ConnectionString_unresolved_expected() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var storage = builder.AddAzureStorage("storage"); + var queues = storage.AddQueues("queues"); + var queue = queues.AddQueue(name: "myqueue"); + + Assert.Equal("Endpoint=\"{storage.outputs.queueEndpoint}\";QueueName=myqueue;", queue.Resource.ConnectionStringExpression.ValueExpression); + } + [Fact] public async Task ResourceNamesBicepValid() { @@ -280,11 +385,11 @@ public async Task ResourceNamesBicepValid() var blobs = storage.AddBlobs("myblobs"); var blob = blobs.AddBlobContainer(name: "myContainer", blobContainerName: "my-blob-container"); var queues = storage.AddQueues("myqueues"); + var queue = queues.AddQueue(name: "myqueue", queueName: "my-queue"); var tables = storage.AddTables("mytables"); var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); await Verify(manifest.BicepText, extension: "bicep"); - } } diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishMode.verified.bicep index 0752967f8a5..1a1653ca102 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishMode.verified.bicep @@ -23,11 +23,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { } } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep index e1550b10c65..802c2888572 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaPublishModeEnableAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep @@ -23,11 +23,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { } } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunMode.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunMode.verified.bicep index 0752967f8a5..1a1653ca102 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunMode.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunMode.verified.bicep @@ -23,11 +23,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { } } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep index e1550b10c65..802c2888572 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureBicepResourceTests.AddAzureStorageViaRunModeAllowSharedKeyAccessOverridesDefaultFalse.verified.bicep @@ -23,11 +23,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { } } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep index 445128945c3..c70f96f3dd5 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureEnvironmentResourceTests.AzurePublishingContext_CapturesParametersAndOutputsCorrectly_WithSnapshot#01.verified.bicep @@ -25,11 +25,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { } } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep index c539e1e9812..f0ee3f1e1e9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -31,6 +31,15 @@ resource myContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@ parent: blobs } +resource queues 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = { + parent: storage +} + +resource myqueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2024-01-01' = { + name: 'my-queue' + parent: queues +} + output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep index 19e5fbea54e..e08f824468e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroup.verified.bicep @@ -7,11 +7,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { name: existingResourceName } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep index ce8d49e094b..545234c9a9e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/ExistingAzureResourceTests.SupportsExistingStorageAccountWithResourceGroupAndStaticArguments.verified.bicep @@ -5,11 +5,6 @@ resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' existing = { name: 'existingResourcename' } -resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { - name: 'default' - parent: storage -} - output blobEndpoint string = storage.properties.primaryEndpoints.blob output queueEndpoint string = storage.properties.primaryEndpoints.queue diff --git a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs index 4a1e253143a..b4e3e66cc4a 100644 --- a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs +++ b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs @@ -91,6 +91,7 @@ await WaitForAllTextAsync(app, { "Aspire-injected EventHubProducerClient namespace: localhost", "Aspire-injected QueueServiceClient URI: http://127.0.0.1:*/devstoreaccount1", + "Aspire-injected QueueClient URI: http://127.0.0.1:*/devstoreaccount1/myqueue1", "Aspire-injected BlobServiceClient URI: http://127.0.0.1:*/devstoreaccount1", "Aspire-injected BlobContainerClient URI: http://127.0.0.1:*/devstoreaccount1/myblobcontainer" }; From 4056f365a71d9b8be0d5f3198dc25547c8ea86a5 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Mon, 26 May 2025 11:28:26 +1000 Subject: [PATCH 3/5] fixup! AzureStorage auto create queues --- .../AzureQueueStorageResource.cs | 17 +++++-- .../AzureBlobStorageContainerSettings.cs | 2 +- .../AzureStorageQueueSettings.cs | 32 ++++++++++-- .../AzureStorageQueuesSettings.cs | 20 ++++---- .../AzureStorageQueueSettingsTests.cs | 50 ++++++++++++------- .../AzureStorageEmulatorFunctionalTests.cs | 16 +++++- 6 files changed, 98 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs index 9376e452d03..0465539ac65 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureQueueStorageResource.cs @@ -16,10 +16,6 @@ public class AzureQueueStorageResource(string name, AzureStorageResource storage IResourceWithParent, IResourceWithAzureFunctionsConfig { - // NOTE: if ever these contants are changed, the AzureStorageQueueSettings in Aspire.Azure.Storage.Queues class should be updated as well. - private const string Endpoint = nameof(Endpoint); - private const string QueueName = nameof(QueueName); - /// /// Gets the parent AzureStorageResource of this AzureQueueStorageResource. /// @@ -39,7 +35,18 @@ internal ReferenceExpression GetConnectionString(string? queueName) } ReferenceExpressionBuilder builder = new(); - builder.Append($"{Endpoint}=\"{ConnectionStringExpression}\";{QueueName}={queueName};"); + + if (Parent.IsEmulator) + { + builder.AppendFormatted(ConnectionStringExpression); + } + else + { + builder.Append($"Endpoint={ConnectionStringExpression}"); + } + + builder.Append($";QueueName={queueName}"); + return builder.Build(); } diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs index cb581a1d1d0..e0cc8651e13 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs @@ -40,7 +40,7 @@ void IConnectionStringSettings.ParseConnectionString(string? connectionString) // when the connection string is built and BlobServiceClient doesn't support escape sequences. } - // Connection string built from a URI? e.g., Endpoint=https://{account_name}.blob.core.windows.net;ContainerName=...; + // Connection string built from a URI? E.g., Endpoint=https://{account_name}.blob.core.windows.net;ContainerName=...; if (builder.TryGetValue("Endpoint", out var endpoint) && endpoint is string) { if (Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out var uri)) diff --git a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs index c7d5e35c9b7..2d10ad7d829 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueueSettings.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data.Common; +using System.Text.RegularExpressions; using Aspire.Azure.Common; namespace Aspire.Azure.Storage.Queues; @@ -9,8 +10,11 @@ namespace Aspire.Azure.Storage.Queues; /// /// Provides the client configuration settings for connecting to Azure Storage queue. /// -public sealed class AzureStorageQueueSettings : AzureStorageQueuesSettings, IConnectionStringSettings +public sealed partial class AzureStorageQueueSettings : AzureStorageQueuesSettings, IConnectionStringSettings { + [GeneratedRegex(@"(?i)QueueName\s*=\s*([^;]+);?", RegexOptions.IgnoreCase)] + private static partial Regex QueueNameRegex(); + /// /// Gets or sets the name of the blob container. /// @@ -25,11 +29,29 @@ void IConnectionStringSettings.ParseConnectionString(string? connectionString) DbConnectionStringBuilder builder = new() { ConnectionString = connectionString }; - // NOTE: if ever these contants are changed, the AzureQueueStorageResource in Aspire.Hosting.Azure.Storage class should be updated as well. - if (builder.TryGetValue("Endpoint", out var endpoint) && builder.TryGetValue("QueueName", out var queueName)) + if (builder.TryGetValue("QueueName", out var containerName)) + { + QueueName = containerName?.ToString(); + + // Remove the QueueName property from the connection string as QueueServiceClient would fail to parse it. + connectionString = QueueNameRegex().Replace(connectionString, ""); + + // NB: we can't remove QueueName by using the DbConnectionStringBuilder as it would escape the AccountKey value + // when the connection string is built and QueueServiceClient doesn't support escape sequences. + } + + // Connection string built from a URI? E.g., Endpoint=https://{account_name}.queue.core.windows.net;QueueName=...; + if (builder.TryGetValue("Endpoint", out var endpoint) && endpoint is string) + { + if (Uri.TryCreate(endpoint.ToString(), UriKind.Absolute, out var uri)) + { + ServiceUri = uri; + } + } + else { - ConnectionString = endpoint.ToString(); - QueueName = queueName.ToString(); + // Otherwise preserve the existing connection string + ConnectionString = connectionString; } } } diff --git a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs index dc780c28748..b0b1d041c38 100644 --- a/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Queues/AzureStorageQueuesSettings.cs @@ -52,16 +52,18 @@ public class AzureStorageQueuesSettings : IConnectionStringSettings void IConnectionStringSettings.ParseConnectionString(string? connectionString) { - if (!string.IsNullOrEmpty(connectionString)) + if (string.IsNullOrEmpty(connectionString)) { - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) - { - ServiceUri = uri; - } - else - { - ConnectionString = connectionString; - } + return; + } + + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + ServiceUri = uri; + } + else + { + ConnectionString = connectionString; } } } diff --git a/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs b/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs index 037e60adf44..c90a10aa9db 100644 --- a/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs +++ b/tests/Aspire.Azure.Storage.Queues.Tests/AzureStorageQueueSettingsTests.cs @@ -9,22 +9,7 @@ namespace Aspire.Hosting.Azure.Tests; public class AzureStorageQueueSettingsTests { - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(";")] - [InlineData("Endpoint=https://example.queues.core.windows.net;")] - [InlineData("QueueName=my-queue;")] - [InlineData("Endpoint=https://example.queueName.core.windows.net;ExtraParam=value;")] - public void ParseConnectionString_invalid_input(string? connectionString) - { - var settings = new AzureStorageQueueSettings(); - - ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); - - Assert.Null(settings.ConnectionString); - Assert.Null(settings.QueueName); - } + private const string EmulatorConnectionString = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;QueueEndpoint=http://127.0.0.1:10000/devstoreaccount1"; [Fact] public void ParseConnectionString_invalid_input_results_in_AE() @@ -40,13 +25,42 @@ public void ParseConnectionString_invalid_input_results_in_AE() [InlineData("Endpoint=https://example.queueName.core.windows.net;QueueName=my-queue;ExtraParam=value")] [InlineData("endpoint=https://example.queueName.core.windows.net;queuename=my-queue")] [InlineData("ENDPOINT=https://example.queueName.core.windows.net;QUEUENAME=my-queue")] - public void ParseConnectionString_valid_input(string connectionString) + [InlineData("Endpoint=\"https://example.queueName.core.windows.net\";QueueName=\"my-queue\"")] + public void ParseConnectionString_With_ServiceUri(string connectionString) + { + var settings = new AzureStorageQueueSettings(); + + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + + Assert.Equal("https://example.queuename.core.windows.net/", settings.ServiceUri?.ToString()); + Assert.Equal("my-queue", settings.QueueName); + } + + [Theory] + [InlineData($"{EmulatorConnectionString};QueueName=my-queue")] + [InlineData($"{EmulatorConnectionString};QueueName=\"my-queue\"")] + public void ParseConnectionString_With_ConnectionString(string connectionString) + { + var settings = new AzureStorageQueueSettings(); + + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + + Assert.Contains(EmulatorConnectionString, settings.ConnectionString, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("QueueName", settings.ConnectionString, StringComparison.OrdinalIgnoreCase); + Assert.Equal("my-queue", settings.QueueName); + Assert.Null(settings.ServiceUri); + } + + [Theory] + [InlineData($"Endpoint=not-a-uri;QueueName=my-queue")] + public void ParseConnectionString_With_NotAUri(string connectionString) { var settings = new AzureStorageQueueSettings(); ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); - Assert.Equal("https://example.queueName.core.windows.net", settings.ConnectionString); + Assert.True(string.IsNullOrEmpty(settings.ConnectionString)); Assert.Equal("my-queue", settings.QueueName); + Assert.Null(settings.ServiceUri); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs index bda7fee6692..99e496e4d7c 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs @@ -157,10 +157,15 @@ public async Task VerifyAzureStorageEmulatorResource() { var blobsResourceName = "BlobConnection"; var blobContainerName = "my-container"; + var queuesResourceName = "QueuesConnection"; + var queueName = "my-queue"; using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); - var blobs = builder.AddAzureStorage("storage").RunAsEmulator().AddBlobs(blobsResourceName); + var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + var blobs = storage.AddBlobs(blobsResourceName); var container = blobs.AddBlobContainer(blobContainerName); + var queues = storage.AddQueues(queuesResourceName); + var queue = queues.AddQueue(queueName); using var app = builder.Build(); await app.StartAsync(); @@ -168,8 +173,12 @@ public async Task VerifyAzureStorageEmulatorResource() var hb = Host.CreateApplicationBuilder(); hb.Configuration[$"ConnectionStrings:{blobsResourceName}"] = await blobs.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); hb.Configuration[$"ConnectionStrings:{blobContainerName}"] = await container.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + hb.Configuration[$"ConnectionStrings:{queuesResourceName}"] = await queues.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + hb.Configuration[$"ConnectionStrings:{queueName}"] = await queue.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); hb.AddAzureBlobClient(blobsResourceName); hb.AddAzureBlobContainerClient(blobContainerName); + hb.AddAzureQueueClient(queuesResourceName); + hb.AddAzureQueue(queueName); using var host = hb.Build(); await host.StartAsync(); @@ -182,6 +191,11 @@ public async Task VerifyAzureStorageEmulatorResource() await blobClient.UploadAsync(BinaryData.FromString("testValue")); var downloadResult = (await blobClient.DownloadContentAsync()).Value; + + var queueServiceClient = host.Services.GetRequiredService(); + var queueClient = host.Services.GetRequiredService(); + await queueClient.CreateIfNotExistsAsync(); // For Aspire 9.3 only + Assert.Equal("testValue", downloadResult.Content.ToString()); } From 9bd5da4cd3d552bf25e113d73e75dc259a13485a Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 22 May 2025 17:29:06 +1000 Subject: [PATCH 4/5] Bump Azure.Provisioning.Storage --- Directory.Packages.props | 4 ++-- .../Aspire.Azure.AI.OpenAI/ConfigurationSchema.json | 4 ---- ...rageExtensionsTests.ResourceNamesBicepValid.verified.bicep | 1 + 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 06a8e1d77f5..a6f8e12632b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ true true 1.0.0 + 1.0.1 8.0.6 @@ -49,7 +50,7 @@ - + @@ -146,7 +147,6 @@ - diff --git a/src/Components/Aspire.Azure.AI.OpenAI/ConfigurationSchema.json b/src/Components/Aspire.Azure.AI.OpenAI/ConfigurationSchema.json index bfcbb0bba02..9ccad96578c 100644 --- a/src/Components/Aspire.Azure.AI.OpenAI/ConfigurationSchema.json +++ b/src/Components/Aspire.Azure.AI.OpenAI/ConfigurationSchema.json @@ -67,10 +67,6 @@ }, "description": "The options to be used to configure logging within the 'System.ClientModel.Primitives.ClientPipeline'." }, - "EnableDistributedTracing": { - "type": "boolean", - "description": "Gets or sets whether distributed tracing should be enabled. If null, this value will be treated as true. The default is null." - }, "NetworkTimeout": { "type": "string", "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", diff --git a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep index f0ee3f1e1e9..0b7641690cd 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -32,6 +32,7 @@ resource myContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@ } resource queues 'Microsoft.Storage/storageAccounts/queueServices@2024-01-01' = { + name: 'default' parent: storage } From 646da8ac5dd76e245de43f1dc0677c547a1d46dd Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Mon, 26 May 2025 12:05:29 +1000 Subject: [PATCH 5/5] fixup! AzureStorage auto create queues --- .../AzureStorageExtensionsTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index 6037ef3f0f1..4207a4d4238 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -338,8 +338,8 @@ public async Task AddQueue_ConnectionString_resolved_expected_RunAsEmulator() var queues = storage.AddQueues("queues"); var queue = queues.AddQueue(name: "myqueue", queueName); - string? blobConntionString = await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync(); - string expected = $"Endpoint=\"{blobConntionString}\";QueueName={queueName};"; + string? conntionString = await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync(); + string expected = $"{conntionString};QueueName={queueName}"; Assert.Equal(expected, await ((IResourceWithConnectionString)queue.Resource).GetConnectionStringAsync()); } @@ -353,13 +353,13 @@ public async Task AddQueue_ConnectionString_resolved_expected() var storagesku = builder.AddParameter("storagesku"); var storage = builder.AddAzureStorage("storage"); - storage.Resource.Outputs["queueEndpoint"] = "https://myblob"; + storage.Resource.Outputs["queueEndpoint"] = "https://myqueue"; var queues = storage.AddQueues("queues"); var queue = queues.AddQueue(name: "myqueue", queueName); string? connectionString = await ((IResourceWithConnectionString)queues.Resource).GetConnectionStringAsync(); - string expected = $"Endpoint=\"{connectionString}\";QueueName={queueName};"; + string expected = $"Endpoint={connectionString};QueueName={queueName}"; Assert.Equal(expected, await ((IResourceWithConnectionString)queue.Resource).GetConnectionStringAsync()); } @@ -373,7 +373,7 @@ public void AddQueue_ConnectionString_unresolved_expected() var queues = storage.AddQueues("queues"); var queue = queues.AddQueue(name: "myqueue"); - Assert.Equal("Endpoint=\"{storage.outputs.queueEndpoint}\";QueueName=myqueue;", queue.Resource.ConnectionStringExpression.ValueExpression); + Assert.Equal("Endpoint={storage.outputs.queueEndpoint};QueueName=myqueue", queue.Resource.ConnectionStringExpression.ValueExpression); } [Fact]