From c04eaa3a109cc23d8f2020f7f2fcc873ddfc5b59 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Wed, 23 Apr 2025 16:16:39 +1000 Subject: [PATCH 01/14] AzureStorage auto create blob containers Resolves #5167 --- .../Program.cs | 1 - .../AzureStorageEndToEnd.AppHost/Program.cs | 3 + .../AzureBlobStorageContainerResource.cs | 51 +++++++++++++ .../AzureBlobStorageResource.cs | 42 ++++++++++- .../AzureStorageEmulatorConnectionString.cs | 2 +- .../AzureStorageExtensions.cs | 75 ++++++++++++++++++- .../AzureStorageResource.cs | 8 +- .../AzureStorageEmulatorFunctionalTests.cs | 36 +++++++++ ...sts.ResourceNamesBicepValid.verified.bicep | 40 ++++++++++ .../AzureStorageExtensionsTests.cs | 16 ++++ 10 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs create mode 100644 tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 16e56ead760..3a8c20df6e6 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -17,7 +17,6 @@ app.MapGet("/", async (BlobServiceClient bsc, QueueServiceClient qsc) => { var container = bsc.GetBlobContainerClient("mycontainer"); - await container.CreateIfNotExistsAsync(); var blobNameAndContent = Guid.NewGuid().ToString(); await container.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index be74f6869cf..8291ddcebe7 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -9,11 +9,14 @@ }); var blobs = storage.AddBlobs("blobs"); +var blobContainer = blobs.AddBlobContainer("mycontainer"); + var queues = storage.AddQueues("queues"); builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) + .WithReference(blobContainer).WaitFor(blobContainer) .WithReference(queues).WaitFor(queues); #if !SKIP_DASHBOARD_REFERENCE diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs new file mode 100644 index 00000000000..7d2ffeb0114 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; +using Azure.Provisioning; + +namespace Aspire.Hosting; + +/// +/// A resource that represents an Azure Blob Storage container. +/// +/// The name of the resource. +/// The that the resource is stored in. +public class AzureBlobStorageContainerResource(string name, AzureBlobStorageResource blobStorage) : Resource(name), + IResourceWithConnectionString, + IResourceWithParent, + IResourceWithAzureFunctionsConfig +{ + /// + /// Gets the connection string template for the manifest for the Azure Blob Storage container resource. + /// + public ReferenceExpression ConnectionStringExpression => Parent.GetConnectionString(Name); + + /// + /// Gets the parent of this . + /// + public AzureBlobStorageResource Parent => blobStorage ?? throw new ArgumentNullException(nameof(blobStorage)); + + internal void ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + => Parent.ApplyAzureFunctionsConfiguration(target, connectionName, Name); + + /// + /// Converts the current instance to a provisioning entity. + /// + /// A instance. + internal global::Azure.Provisioning.Storage.BlobContainer ToProvisioningEntity() + { + global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(Name)); + + if (Name is not null) + { + blobContainer.Name = Name; + } + + return blobContainer; + } + + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + => ApplyAzureFunctionsConfiguration(target, connectionName); +} diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index e28c1f289e7..31a07bb10ab 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.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,8 @@ public class AzureBlobStorageResource(string name, AzureStorageResource storage) IResourceWithParent, IResourceWithAzureFunctionsConfig { + internal List BlobContainers { get; } = []; + /// /// Gets the parent AzureStorageResource of this AzureBlobStorageResource. /// @@ -26,7 +29,25 @@ public class AzureBlobStorageResource(string name, AzureStorageResource storage) public ReferenceExpression ConnectionStringExpression => Parent.GetBlobConnectionString(); - void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + internal ReferenceExpression GetConnectionString(string? blobContainerName) + { + if (string.IsNullOrEmpty(blobContainerName)) + { + return ConnectionStringExpression; + } + + var builder = new ReferenceExpressionBuilder(); + builder.Append($"{ConnectionStringExpression}"); + + if (!string.IsNullOrEmpty(blobContainerName)) + { + builder.Append($"/{blobContainerName}"); + } + + return builder.Build(); + } + + internal void ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName, string? blobContainerName = null) { if (Parent.IsEmulator) { @@ -42,10 +63,29 @@ void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDiction // uses the queue service for its internal bookkeeping on blob triggers. target[$"{connectionName}__blobServiceUri"] = Parent.BlobEndpoint; target[$"{connectionName}__queueServiceUri"] = Parent.QueueEndpoint; + // Injected to support Aspire client integration for Azure Storage. // We don't inject the queue resource here since we on;y want it to // be accessible by the Functions host. target[$"{AzureStorageResource.BlobsConnectionKeyPrefix}__{connectionName}__ServiceUri"] = Parent.BlobEndpoint; + + if (blobContainerName is not null) + { + target[$"{AzureStorageResource.BlobsConnectionKeyPrefix}__{connectionName}__BlobContainerName"] = blobContainerName; + } } } + + /// + /// Converts the current instance to a provisioning entity. + /// + /// A instance. + internal global::Azure.Provisioning.Storage.BlobService ToProvisioningEntity() + { + global::Azure.Provisioning.Storage.BlobService service = new(Infrastructure.NormalizeBicepIdentifier(Name)); + return service; + } + + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) + => ApplyAzureFunctionsConfiguration(target, connectionName); } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs index ab522600a8c..d133940da24 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs @@ -12,7 +12,7 @@ internal static class AzureStorageEmulatorConnectionString private static void AppendEndpointExpression(ReferenceExpressionBuilder builder, string key, EndpointReference endpoint) { - builder.Append($"{key}=http://{endpoint.Property(EndpointProperty.IPV4Host)}:{endpoint.Property(EndpointProperty.Port)}/devstoreaccount1;"); + builder.Append($"{key}=http://{endpoint.Property(EndpointProperty.IPV4Host)}:{endpoint.Property(EndpointProperty.Port)}/devstoreaccount1"); } public static ReferenceExpression Create(EndpointReference? blobEndpoint = null, EndpointReference? queueEndpoint = null, EndpointReference? tableEndpoint = null) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 4de65cc7e98..072c5902c87 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -81,6 +81,18 @@ public static IResourceBuilder AddAzureStorage(this IDistr infrastructure.Add(new ProvisioningOutput("queueEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.QueueUri }); infrastructure.Add(new ProvisioningOutput("tableEndpoint", typeof(string)) { Value = storageAccount.PrimaryEndpoints.TableUri }); + var azureResource = (AzureStorageResource)infrastructure.AspireResource; + + foreach (var blobStorageResources in azureResource.Blobs) + { + foreach (var blobContainer in blobStorageResources.BlobContainers) + { + var cdkBlobContainer = blobContainer.ToProvisioningEntity(); + cdkBlobContainer.Parent = blobs; + infrastructure.Add(cdkBlobContainer); + } + } + // We need to output name to externalize role assignments. infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = storageAccount.Name }); }; @@ -126,15 +138,36 @@ public static IResourceBuilder RunAsEmulator(this IResourc builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false); - - if (connectionString == null) + if (connectionString is null) { - throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); + throw new DistributedApplicationException($"BeforeResourceStartedEvent 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) => + { + var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false); + if (connectionString is null) + { + throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); + } + + if (blobServiceClient is null) + { + throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource."); + } + + foreach (var blobStorageResources in builder.Resource.Blobs) + { + foreach (var blobContainer in blobStorageResources.BlobContainers) + { + await blobServiceClient.GetBlobContainerClient(blobContainer.Name).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); + } + } + }); + var healthCheckKey = $"{builder.Resource.Name}_check"; builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp => @@ -281,6 +314,26 @@ public static IResourceBuilder AddBlobs(this IResource ArgumentException.ThrowIfNullOrEmpty(name); var resource = new AzureBlobStorageResource(name, builder.Resource); + builder.Resource.Blobs.Add(resource); + + return builder.ApplicationBuilder.AddResource(resource); + } + + /// + /// Creates a builder for the which can be referenced to get the Azure Storage blob container endpoint for the storage account. + /// + /// The for / + /// The name of the resource. + /// An for the . + public static IResourceBuilder AddBlobContainer(this IResourceBuilder builder, [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + AzureBlobStorageContainerResource resource = new(name, builder.Resource); + + builder.Resource.BlobContainers.Add(resource); + return builder.ApplicationBuilder.AddResource(resource); } @@ -314,6 +367,22 @@ public static IResourceBuilder AddQueues(this IResour return builder.ApplicationBuilder.AddResource(resource); } + /// + /// Allows setting the properties of an Azure Blob Storage container resource. + /// + /// The Azure Blob Storage container resource builder. + /// A method that can be used for customizing the . + /// A reference to the . + public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + configure(builder.Resource); + + return builder; + } + /// /// 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 797aec6c816..3b4d3acb1d9 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs @@ -15,13 +15,15 @@ namespace Aspire.Hosting.Azure; public class AzureStorageResource(string name, Action configureInfrastructure) : AzureProvisioningResource(name, configureInfrastructure), IResourceWithEndpoints, IResourceWithAzureFunctionsConfig { + internal const string BlobsConnectionKeyPrefix = "Aspire__Azure__Storage__Blobs"; + internal const string QueuesConnectionKeyPrefix = "Aspire__Azure__Storage__Queues"; + internal const string TablesConnectionKeyPrefix = "Aspire__Azure__Data__Tables"; + private EndpointReference EmulatorBlobEndpoint => new(this, "blob"); private EndpointReference EmulatorQueueEndpoint => new(this, "queue"); private EndpointReference EmulatorTableEndpoint => new(this, "table"); - internal const string BlobsConnectionKeyPrefix = "Aspire__Azure__Storage__Blobs"; - internal const string QueuesConnectionKeyPrefix = "Aspire__Azure__Storage__Queues"; - internal const string TablesConnectionKeyPrefix = "Aspire__Azure__Data__Tables"; + internal List Blobs { get; } = []; /// /// Gets the "blobEndpoint" output reference from the bicep template for the Azure Storage resource. diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs index 48e1e361d07..ef68d732912 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs @@ -88,4 +88,40 @@ public async Task VerifyAzureStorageEmulatorResource() var downloadResult = (await blobClient.DownloadContentAsync()).Value; Assert.Equal("testValue", downloadResult.Content.ToString()); } + + [Fact] + [RequiresDocker] + public async Task VerifyAzureStorageEmulatorBlobContainer() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); + var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + var blobs = storage.AddBlobs("BlobConnection"); + var blobContainer = blobs.AddBlobContainer("testblobcontainer"); + + using var app = builder.Build(); + await app.StartAsync(); + + var hb = Host.CreateApplicationBuilder(); + hb.Configuration["ConnectionStrings:BlobConnection"] = await blobs.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + hb.AddAzureBlobClient("BlobConnection"); + + using var host = hb.Build(); + await host.StartAsync(); + + var rns = app.Services.GetRequiredService(); + await rns.WaitForResourceHealthyAsync(blobContainer.Resource.Name, CancellationToken.None); + + var serviceClient = host.Services.GetRequiredService(); + var blobContainerClient = serviceClient.GetBlobContainerClient("testblobcontainer"); + + var exists = await blobContainerClient.ExistsAsync(); + + var blobNameAndContent = Guid.NewGuid().ToString(); + var response = await blobContainerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + + var blobClient = blobContainerClient.GetBlobClient(blobNameAndContent); + + var downloadResult = (await blobClient.DownloadContentAsync()).Value; + Assert.Equal(blobNameAndContent, downloadResult.Content.ToString()); + } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep new file mode 100644 index 00000000000..940bb056d9d --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -0,0 +1,40 @@ +@description('The location for the resource(s) to be deployed.') +param location string = resourceGroup().location + +resource storage 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: take('storage${uniqueString(resourceGroup().id)}', 24) + kind: 'StorageV2' + location: location + sku: { + name: 'Standard_GRS' + } + properties: { + accessTier: 'Hot' + allowSharedKeyAccess: false + minimumTlsVersion: 'TLS1_2' + networkAcls: { + defaultAction: 'Allow' + } + } + tags: { + 'aspire-resource-name': 'storage' + } +} + +resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + name: 'default' + parent: storage +} + +resource my_blob_container 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + name: 'my-blob-container' + parent: blobs +} + +output blobEndpoint string = storage.properties.primaryEndpoints.blob + +output queueEndpoint string = storage.properties.primaryEndpoints.queue + +output tableEndpoint string = storage.properties.primaryEndpoints.table + +output name string = storage.name \ No newline at end of file diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index 8781fa867cd..4f17330dec9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -163,4 +163,20 @@ public async Task AddAzureStorage_RunAsEmulator_SetSkipApiVersionCheck() Assert.Contains("--skipApiVersionCheck", args); } + + [Fact] + public async Task ResourceNamesBicepValid() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var storage = builder.AddAzureStorage("storage"); + + var blobs = storage.AddBlobs("myblobs"); + var blob = blobs.AddBlobContainer("my-blob-container"); + var queues = storage.AddQueues("myqueues"); + var tables = storage.AddTables("mytables"); + + var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); + + await Verifier.Verify(manifest.BicepText, extension: "bicep"); + } } From 2b8a4b7568374abcc65487be15833bf202e109fd Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Tue, 29 Apr 2025 13:27:14 +1000 Subject: [PATCH 02/14] fixup! AzureStorage auto create blob containers --- .../AzureBlobStorageContainerResource.cs | 29 ++++++++++++++----- .../AzureStorageExtensions.cs | 7 +++-- .../AzureStorageExtensionsTests.cs | 3 +- ...sts.ResourceNamesBicepValid.verified.bicep | 0 4 files changed, 28 insertions(+), 11 deletions(-) rename tests/Aspire.Hosting.Azure.Tests/{ => Snapshots}/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep (100%) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs index 7d2ffeb0114..b8b3070b68e 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Azure.Provisioning; @@ -11,12 +13,19 @@ namespace Aspire.Hosting; /// A resource that represents an Azure Blob Storage container. /// /// The name of the resource. -/// The that the resource is stored in. -public class AzureBlobStorageContainerResource(string name, AzureBlobStorageResource blobStorage) : Resource(name), +/// The name of the blob container. +/// The that the resource is stored in. +public class AzureBlobStorageContainerResource(string name, string blobContainerName, AzureBlobStorageResource parent) : Resource(name), IResourceWithConnectionString, IResourceWithParent, IResourceWithAzureFunctionsConfig { + + /// + /// Gets the blob container name. + /// + public string BlobContainerName { get; } = ThrowIfNullOrEmpty(blobContainerName); + /// /// Gets the connection string template for the manifest for the Azure Blob Storage container resource. /// @@ -25,7 +34,7 @@ public class AzureBlobStorageContainerResource(string name, AzureBlobStorageReso /// /// Gets the parent of this . /// - public AzureBlobStorageResource Parent => blobStorage ?? throw new ArgumentNullException(nameof(blobStorage)); + public AzureBlobStorageResource Parent => parent ?? throw new ArgumentNullException(nameof(parent)); internal void ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) => Parent.ApplyAzureFunctionsConfiguration(target, connectionName, Name); @@ -36,16 +45,20 @@ internal void ApplyAzureFunctionsConfiguration(IDictionary targe /// A instance. internal global::Azure.Provisioning.Storage.BlobContainer ToProvisioningEntity() { - global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(Name)); - - if (Name is not null) + global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(Name)) { - blobContainer.Name = Name; - } + Name = BlobContainerName + }; return blobContainer; } + private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + ArgumentException.ThrowIfNullOrEmpty(argument, paramName); + return argument; + } + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) => ApplyAzureFunctionsConfiguration(target, connectionName); } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 072c5902c87..3d3beba5f9d 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -324,13 +324,16 @@ public static IResourceBuilder AddBlobs(this IResource /// /// The for / /// The name of the resource. + /// The name of the blob container. /// An for the . - public static IResourceBuilder AddBlobContainer(this IResourceBuilder builder, [ResourceName] string name) + public static IResourceBuilder AddBlobContainer(this IResourceBuilder builder, [ResourceName] string name, string? blobContainerName = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - AzureBlobStorageContainerResource resource = new(name, builder.Resource); + blobContainerName ??= name; + + AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource); builder.Resource.BlobContainers.Add(resource); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index 4f17330dec9..cab5022955a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -177,6 +177,7 @@ public async Task ResourceNamesBicepValid() var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); - await Verifier.Verify(manifest.BicepText, extension: "bicep"); + await Verifier.Verify(manifest.BicepText, extension: "bicep") + .UseDirectory("Snapshots"); } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep similarity index 100% rename from tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep rename to tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep From a0a489d87839d99b9f8853cd8fe533a0d8fafea4 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Tue, 29 Apr 2025 14:10:42 +1000 Subject: [PATCH 03/14] Revert AF-related changes --- .../AzureBlobStorageContainerResource.cs | 9 +-------- .../AzureBlobStorageResource.cs | 10 +--------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs index b8b3070b68e..fd3c3826ca3 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs @@ -17,8 +17,7 @@ namespace Aspire.Hosting; /// The that the resource is stored in. public class AzureBlobStorageContainerResource(string name, string blobContainerName, AzureBlobStorageResource parent) : Resource(name), IResourceWithConnectionString, - IResourceWithParent, - IResourceWithAzureFunctionsConfig + IResourceWithParent { /// @@ -36,9 +35,6 @@ public class AzureBlobStorageContainerResource(string name, string blobContainer /// public AzureBlobStorageResource Parent => parent ?? throw new ArgumentNullException(nameof(parent)); - internal void ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) - => Parent.ApplyAzureFunctionsConfiguration(target, connectionName, Name); - /// /// Converts the current instance to a provisioning entity. /// @@ -58,7 +54,4 @@ private static string ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgu ArgumentException.ThrowIfNullOrEmpty(argument, paramName); return argument; } - - void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) - => ApplyAzureFunctionsConfiguration(target, connectionName); } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index 31a07bb10ab..4d62576e328 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -47,7 +47,7 @@ internal ReferenceExpression GetConnectionString(string? blobContainerName) return builder.Build(); } - internal void ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName, string? blobContainerName = null) + void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) { if (Parent.IsEmulator) { @@ -68,11 +68,6 @@ internal void ApplyAzureFunctionsConfiguration(IDictionary targe // We don't inject the queue resource here since we on;y want it to // be accessible by the Functions host. target[$"{AzureStorageResource.BlobsConnectionKeyPrefix}__{connectionName}__ServiceUri"] = Parent.BlobEndpoint; - - if (blobContainerName is not null) - { - target[$"{AzureStorageResource.BlobsConnectionKeyPrefix}__{connectionName}__BlobContainerName"] = blobContainerName; - } } } @@ -85,7 +80,4 @@ internal void ApplyAzureFunctionsConfiguration(IDictionary targe global::Azure.Provisioning.Storage.BlobService service = new(Infrastructure.NormalizeBicepIdentifier(Name)); return service; } - - void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) - => ApplyAzureFunctionsConfiguration(target, connectionName); } From 875e113f70b4ed7e0c0cdf773acf4f21ce093e01 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Tue, 29 Apr 2025 14:19:54 +1000 Subject: [PATCH 04/14] fixup! AzureStorage auto create blob containers --- tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs | 2 +- ...torageExtensionsTests.ResourceNamesBicepValid.verified.bicep | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index cab5022955a..89a489939b2 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -171,7 +171,7 @@ public async Task ResourceNamesBicepValid() var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("myblobs"); - var blob = blobs.AddBlobContainer("my-blob-container"); + var blob = blobs.AddBlobContainer("myContainer", "my-blob-container"); var queues = storage.AddQueues("myqueues"); var tables = storage.AddTables("mytables"); 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 940bb056d9d..c539e1e9812 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -26,7 +26,7 @@ resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { parent: storage } -resource my_blob_container 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { +resource myContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { name: 'my-blob-container' parent: blobs } From 7078ea234ddb015ff9266d2a6c04faa7fa1438bb Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Wed, 30 Apr 2025 16:45:46 +1000 Subject: [PATCH 05/14] AddAzureBlobContainerClient() and various fixes --- Directory.Build.props | 2 +- .../Program.cs | 6 +- .../AzureStorageEndToEnd.AppHost/Program.cs | 12 ++- .../AzureBlobStorageContainerResource.cs | 4 +- .../AzureBlobStorageResource.cs | 2 +- .../AzureStorageEmulatorConnectionString.cs | 2 +- .../AzureStorageExtensions.cs | 28 ++----- ...bStorageExtensions.BlobStorageComponent.cs | 66 +++++++++++++++++ ...nt.AzureBlobStorageContainerHealthCheck.cs | 40 ++++++++++ ...xtensions.BlobStorageContainerComponent.cs | 73 ++++++++++++++++++ .../AspireBlobStorageExtensions.cs | 74 ++++++------------- .../AzureBlobStorageContainerSettings.cs | 59 +++++++++++++++ .../AzureStorageBlobsSettings.cs | 2 +- .../AspireMicrosoftAzureCosmosExtensions.cs | 1 + ...sts.ResourceNamesBicepValid.verified.bicep | 2 +- 15 files changed, 289 insertions(+), 84 deletions(-) create mode 100644 src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageComponent.cs create mode 100644 src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs create mode 100644 src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs create mode 100644 src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs diff --git a/Directory.Build.props b/Directory.Build.props index 3d87e1d7925..af7b34c6cb7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,7 +37,7 @@ - $(NoWarn);xUnit1051 + $(NoWarn);xUnit1051;CS0162;CS1591;CS9113;IDE0059;IDE0051;IDE2000;IDE0005 diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 3a8c20df6e6..47058b057b8 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -9,14 +9,16 @@ builder.AddServiceDefaults(); builder.AddAzureBlobClient("blobs"); +builder.AddAzureBlobContainerClient("foocontainer"); + builder.AddAzureQueueClient("queues"); var app = builder.Build(); app.MapDefaultEndpoints(); -app.MapGet("/", async (BlobServiceClient bsc, QueueServiceClient qsc) => +app.MapGet("/", async (BlobServiceClient bsc, QueueServiceClient qsc, BlobContainerClient bcc) => { - var container = bsc.GetBlobContainerClient("mycontainer"); + var container = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); var blobNameAndContent = Guid.NewGuid().ToString(); await container.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 8291ddcebe7..aba1f9e6eea 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -9,14 +9,22 @@ }); var blobs = storage.AddBlobs("blobs"); -var blobContainer = blobs.AddBlobContainer("mycontainer"); +blobs.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); +blobs.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); var queues = storage.AddQueues("queues"); +var storage2 = builder.AddAzureStorage("storage2").RunAsEmulator(container => +{ + container.WithDataBindMount(); +}); +var blobs2 = storage2.AddBlobs("blobs2"); +var blobContainer2 = blobs2.AddBlobContainer("foocontainer", blobContainerName: "foo-container"); + builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) - .WithReference(blobContainer).WaitFor(blobContainer) + .WithReference(blobContainer2).WaitFor(blobContainer2) .WithReference(queues).WaitFor(queues); #if !SKIP_DASHBOARD_REFERENCE diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs index fd3c3826ca3..bd9e6148dbf 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs @@ -28,7 +28,7 @@ public class AzureBlobStorageContainerResource(string name, string blobContainer /// /// Gets the connection string template for the manifest for the Azure Blob Storage container resource. /// - public ReferenceExpression ConnectionStringExpression => Parent.GetConnectionString(Name); + public ReferenceExpression ConnectionStringExpression => Parent.GetConnectionString(BlobContainerName); /// /// Gets the parent of this . @@ -41,7 +41,7 @@ public class AzureBlobStorageContainerResource(string name, string blobContainer /// A instance. internal global::Azure.Provisioning.Storage.BlobContainer ToProvisioningEntity() { - global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(Name)) + global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(BlobContainerName)) { Name = BlobContainerName }; diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index 4d62576e328..80c39359ed7 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -41,7 +41,7 @@ internal ReferenceExpression GetConnectionString(string? blobContainerName) if (!string.IsNullOrEmpty(blobContainerName)) { - builder.Append($"/{blobContainerName}"); + builder.Append($"ContainerName={blobContainerName};"); } return builder.Build(); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs index d133940da24..ab522600a8c 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs @@ -12,7 +12,7 @@ internal static class AzureStorageEmulatorConnectionString private static void AppendEndpointExpression(ReferenceExpressionBuilder builder, string key, EndpointReference endpoint) { - builder.Append($"{key}=http://{endpoint.Property(EndpointProperty.IPV4Host)}:{endpoint.Property(EndpointProperty.Port)}/devstoreaccount1"); + builder.Append($"{key}=http://{endpoint.Property(EndpointProperty.IPV4Host)}:{endpoint.Property(EndpointProperty.Port)}/devstoreaccount1;"); } public static ReferenceExpression Create(EndpointReference? blobEndpoint = null, EndpointReference? queueEndpoint = null, EndpointReference? tableEndpoint = null) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 3d3beba5f9d..78da68427d0 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -148,22 +148,22 @@ public static IResourceBuilder RunAsEmulator(this IResourc builder.ApplicationBuilder.Eventing.Subscribe(builder.Resource, async (@event, ct) => { - var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false); - if (connectionString is null) + if (blobServiceClient is null) { - throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); + throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource."); } - if (blobServiceClient is null) + var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false); + if (connectionString is null) { - throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource."); + throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); } foreach (var blobStorageResources in builder.Resource.Blobs) { foreach (var blobContainer in blobStorageResources.BlobContainers) { - await blobServiceClient.GetBlobContainerClient(blobContainer.Name).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); + await blobServiceClient.GetBlobContainerClient(blobContainer.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); } } }); @@ -370,22 +370,6 @@ public static IResourceBuilder AddQueues(this IResour return builder.ApplicationBuilder.AddResource(resource); } - /// - /// Allows setting the properties of an Azure Blob Storage container resource. - /// - /// The Azure Blob Storage container resource builder. - /// A method that can be used for customizing the . - /// A reference to the . - public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(configure); - - configure(builder.Resource); - - return builder; - } - /// /// 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/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageComponent.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageComponent.cs new file mode 100644 index 00000000000..aedfad0cea8 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageComponent.cs @@ -0,0 +1,66 @@ +// 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.Blobs; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Storage.Blobs; +using HealthChecks.Azure.Storage.Blobs; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +partial class AspireBlobStorageExtensions +{ + private sealed class BlobStorageComponent : AzureComponent + { + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageBlobsSettings 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 BlobServiceClient 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 BlobServiceClient(connectionString, options) : + cred is not null ? new BlobServiceClient(settings.ServiceUri, cred, options) : + new BlobServiceClient(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(AzureStorageBlobsSettings settings, IConfiguration configuration) + { + configuration.Bind(settings); + } + + protected override IHealthCheck CreateHealthCheck(BlobServiceClient client, AzureStorageBlobsSettings settings) + => new AzureBlobStorageHealthCheck(client); + + protected override bool GetHealthCheckEnabled(AzureStorageBlobsSettings settings) + => !settings.DisableHealthChecks; + + protected override TokenCredential? GetTokenCredential(AzureStorageBlobsSettings settings) + => settings.Credential; + + protected override bool GetMetricsEnabled(AzureStorageBlobsSettings settings) + => false; + + protected override bool GetTracingEnabled(AzureStorageBlobsSettings settings) + => !settings.DisableTracing; + } +} diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs new file mode 100644 index 00000000000..764b91a8b24 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Storage.Blobs; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +partial class AspireBlobStorageExtensions +{ + partial class BlobStorageContainerComponent + { + /// + /// Azure Blob Storage container health check. + /// + /// + /// The used to perform Azure Blob Storage container operations. + /// Azure SDK recommends treating clients as singletons , + /// so this should be the exact same instance used by other parts of the application. + /// + private sealed class AzureBlobStorageContainerHealthCheck(BlobContainerClient blobContainerClient) : IHealthCheck + { + private readonly BlobContainerClient _blobServiceClient = blobContainerClient ?? throw new ArgumentNullException(nameof(blobContainerClient)); + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await _blobServiceClient.ExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } + } + } +} diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs new file mode 100644 index 00000000000..b6fc3deb45a --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs @@ -0,0 +1,73 @@ +// 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.Blobs; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +partial class AspireBlobStorageExtensions +{ + private sealed partial class BlobStorageContainerComponent : AzureComponent + { + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, AzureBlobStorageContainerSettings settings, string connectionName, string configurationSectionName) + { + return ((IAzureClientFactoryBuilderWithCredential)azureFactoryBuilder).RegisterClientFactory((options, cred) => + { + if (string.IsNullOrEmpty(settings.BlobContainerName)) + { + throw new InvalidOperationException($"The connection string '{connectionName}' does not exist or is missing the container name."); + } + + var connectionString = settings.ConnectionString; + if (string.IsNullOrEmpty(connectionString) && settings.ServiceUri is null) + { + throw new InvalidOperationException($"A BlobServiceClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'ServiceUri' in the '{configurationSectionName}' configuration section."); + } + + var blobServiceClient = !string.IsNullOrEmpty(connectionString) ? new BlobServiceClient(connectionString, options) : + cred is not null ? new BlobServiceClient(settings.ServiceUri, cred, options) : + new BlobServiceClient(settings.ServiceUri, options); + + var containerClient = blobServiceClient.GetBlobContainerClient(settings.BlobContainerName); + return containerClient; + + }, 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(AzureBlobStorageContainerSettings settings, IConfiguration configuration) + { + configuration.Bind(settings); + } + + protected override IHealthCheck CreateHealthCheck(BlobContainerClient client, AzureBlobStorageContainerSettings settings) + => new AzureBlobStorageContainerHealthCheck(client); + + protected override bool GetHealthCheckEnabled(AzureBlobStorageContainerSettings settings) + => !settings.DisableHealthChecks; + + protected override TokenCredential? GetTokenCredential(AzureBlobStorageContainerSettings settings) + => settings.Credential; + + protected override bool GetMetricsEnabled(AzureBlobStorageContainerSettings settings) + => false; + + protected override bool GetTracingEnabled(AzureBlobStorageContainerSettings settings) + => !settings.DisableTracing; + } +} diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs index 5a6ca8747ef..7f434c57d41 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs @@ -1,23 +1,17 @@ // 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.Blobs; -using Azure.Core; using Azure.Core.Extensions; using Azure.Storage.Blobs; -using HealthChecks.Azure.Storage.Blobs; -using Microsoft.Extensions.Azure; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Microsoft.Extensions.Hosting; /// /// Provides extension methods for registering as a singleton in the services provided by the . /// -public static class AspireBlobStorageExtensions +public static partial class AspireBlobStorageExtensions { private const string DefaultConfigSectionName = "Aspire:Azure:Storage:Blobs"; @@ -65,51 +59,29 @@ public static void AddKeyedAzureBlobClient( new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); } - private sealed class BlobStorageComponent : AzureComponent + /// + /// 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:Blobs" section. + /// + /// Neither nor is provided. + /// - or - + /// is not provided in the configuration section. + /// + public static void AddAzureBlobContainerClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action>? configureClientBuilder = null) { - protected override IAzureClientBuilder AddClient( - AzureClientFactoryBuilder azureFactoryBuilder, AzureStorageBlobsSettings 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 BlobServiceClient 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 BlobServiceClient(connectionString, options) : - cred is not null ? new BlobServiceClient(settings.ServiceUri, cred, options) : - new BlobServiceClient(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(AzureStorageBlobsSettings settings, IConfiguration configuration) - { - configuration.Bind(settings); - } - - protected override IHealthCheck CreateHealthCheck(BlobServiceClient client, AzureStorageBlobsSettings settings) - => new AzureBlobStorageHealthCheck(client); - - protected override bool GetHealthCheckEnabled(AzureStorageBlobsSettings settings) - => !settings.DisableHealthChecks; - - protected override TokenCredential? GetTokenCredential(AzureStorageBlobsSettings settings) - => settings.Credential; - - protected override bool GetMetricsEnabled(AzureStorageBlobsSettings settings) - => false; + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); - protected override bool GetTracingEnabled(AzureStorageBlobsSettings settings) - => !settings.DisableTracing; + new BlobStorageContainerComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); } } diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs new file mode 100644 index 00000000000..29bf0d1adf7 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs @@ -0,0 +1,59 @@ +// 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 System.Globalization; +using System.Text; +using Aspire.Azure.Common; + +namespace Aspire.Azure.Storage.Blobs; + +/// +/// Provides the client configuration settings for connecting to Azure Blob Storage container. +/// +public sealed class AzureBlobStorageContainerSettings : AzureStorageBlobsSettings, IConnectionStringSettings +{ + public string? BlobContainerName { get; set; } + + void IConnectionStringSettings.ParseConnectionString(string? connectionString) + { + if (string.IsNullOrEmpty(connectionString)) + { + return; + } + + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + ServiceUri = uri; + + // TODO: how do we get the container name from the URI? + } + else + { + var connectionBuilder = new DbConnectionStringBuilder() + { + ConnectionString = connectionString + }; + + if (connectionBuilder.TryGetValue("ContainerName", out var containerValue)) + { + BlobContainerName = (string)containerValue; + + // Remove it from the connection string, it is our custom property. + connectionBuilder["ContainerName"] = null; + } + + // We can't use connectionBuilder.ConnectionString here, because connectionBuilder escapes values + // adding quotes and other characters, which upset the Azure SDK. + // So, we have rebuilt the connection string manually. + + StringBuilder builder = new(); + foreach (string keyword in connectionBuilder.Keys) + { + builder.Append(CultureInfo.InvariantCulture, $"{keyword}={connectionBuilder[keyword]};"); + } + + ConnectionString = builder.ToString(); + } + } +} diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AzureStorageBlobsSettings.cs b/src/Components/Aspire.Azure.Storage.Blobs/AzureStorageBlobsSettings.cs index c0dce6e6421..d1b56a420b7 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AzureStorageBlobsSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AzureStorageBlobsSettings.cs @@ -9,7 +9,7 @@ namespace Aspire.Azure.Storage.Blobs; /// /// Provides the client configuration settings for connecting to Azure Blob Storage. /// -public sealed class AzureStorageBlobsSettings : IConnectionStringSettings +public class AzureStorageBlobsSettings : IConnectionStringSettings { /// /// Gets or sets the connection string used to connect to the blob service. diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs index 61428f9f824..4b9644317b1 100644 --- a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireMicrosoftAzureCosmosExtensions.cs @@ -198,6 +198,7 @@ public static CosmosDatabaseBuilder AddKeyedAzureCosmosDatabase( return CosmosUtils.ParseConnectionString(connectionString); } + private static MicrosoftAzureCosmosSettings GetSettings( this IHostApplicationBuilder builder, string connectionName, 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..940bb056d9d 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -26,7 +26,7 @@ resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { parent: storage } -resource myContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { +resource my_blob_container 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { name: 'my-blob-container' parent: blobs } From 7a68dc40352698af22d88e41cc2fb9703b06ce7d Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 1 May 2025 11:17:13 +1000 Subject: [PATCH 06/14] AddKeyedAzureBlobContainerClient --- .../Program.cs | 34 ++++++- .../AzureStorageEndToEnd.AppHost/Program.cs | 3 +- .../AspireBlobStorageExtensions.cs | 89 +++++++++++++++---- .../AzureBlobStorageContainerSettings.cs | 2 +- 4 files changed, 107 insertions(+), 21 deletions(-) diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 47058b057b8..9c76e008acb 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -9,18 +9,46 @@ builder.AddServiceDefaults(); builder.AddAzureBlobClient("blobs"); -builder.AddAzureBlobContainerClient("foocontainer"); +//builder.AddAzureBlobContainerClient("foocontainer"); +builder.AddKeyedAzureBlobContainerClient("mycontainer2"); +builder.AddKeyedAzureBlobContainerClient("foocontainer"); builder.AddAzureQueueClient("queues"); var app = builder.Build(); app.MapDefaultEndpoints(); -app.MapGet("/", async (BlobServiceClient bsc, QueueServiceClient qsc, BlobContainerClient bcc) => + +app.MapGet("/", async ([FromKeyedServices("mycontainer2")] BlobContainerClient keyedContainerClinet1, + [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClinet2) => { - var container = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); + var blobNames = new List(); + var blobNameAndContent = Guid.NewGuid().ToString(); + + await keyedContainerClinet1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + await keyedContainerClinet2.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + + var blobs = keyedContainerClinet1.GetBlobsAsync(); + blobNames.Add(keyedContainerClinet1.Uri.ToString()); + await foreach (var blob in blobs) + { + blobNames.Add(blob.Name); + } + blobs = keyedContainerClinet2.GetBlobsAsync(); + blobNames.Add(keyedContainerClinet2.Uri.ToString()); + await foreach (var blob in blobs) + { + blobNames.Add(blob.Name); + } + + return blobNames; +}); +app.MapGet("/test", async (BlobServiceClient bsc, QueueServiceClient qsc, BlobContainerClient fooContainerClinet) => +{ var blobNameAndContent = Guid.NewGuid().ToString(); + + var container = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); await container.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); var blobs = container.GetBlobsAsync(); diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index aba1f9e6eea..351a152ce9a 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -10,7 +10,7 @@ var blobs = storage.AddBlobs("blobs"); blobs.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); -blobs.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); +var blobContainer1 = blobs.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); var queues = storage.AddQueues("queues"); @@ -24,6 +24,7 @@ builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) + .WithReference(blobContainer1).WaitFor(blobContainer1) .WithReference(blobContainer2).WaitFor(blobContainer2) .WithReference(queues).WaitFor(queues); diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs index 7f434c57d41..0669e3d23ea 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs @@ -16,15 +16,23 @@ public static partial class AspireBlobStorageExtensions private const string DefaultConfigSectionName = "Aspire:Azure:Storage:Blobs"; /// - /// 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 . + /// + /// 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:Blobs" section. - /// Thrown when neither nor is provided. + /// + /// Neither nor is provided. + /// public static void AddAzureBlobClient( this IHostApplicationBuilder builder, string connectionName, @@ -38,15 +46,25 @@ public static void AddAzureBlobClient( } /// - /// 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:Blobs:{name}" section. - /// Thrown when neither nor is provided. + /// + /// Neither nor is provided. + /// public static void AddKeyedAzureBlobClient( this IHostApplicationBuilder builder, string name, @@ -60,13 +78,18 @@ public static void AddKeyedAzureBlobClient( } /// - /// 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:Blobs" section. /// /// Neither nor is provided. @@ -84,4 +107,38 @@ public static void AddAzureBlobContainerClient( new BlobStorageContainerComponent().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:Blobs:{name}" section. + /// + /// Neither nor is provided. + /// - or - + /// is not provided in the configuration section. + /// + public static void AddKeyedAzureBlobContainerClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + new BlobStorageContainerComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + } } diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs index 29bf0d1adf7..2eb3530c8c9 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs @@ -44,7 +44,7 @@ void IConnectionStringSettings.ParseConnectionString(string? connectionString) } // We can't use connectionBuilder.ConnectionString here, because connectionBuilder escapes values - // adding quotes and other characters, which upset the Azure SDK. + // adding quotes and other characters, which upsets the Azure SDK. // So, we have rebuilt the connection string manually. StringBuilder builder = new(); From 01918deaa60eec341e90d26046e4b76eab1d2ce6 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 1 May 2025 16:41:49 +1000 Subject: [PATCH 07/14] AddKeyedAzureBlobContainerClient --- .../Program.cs | 56 ++++++++-------- .../AzureStorageEndToEnd.AppHost/Program.cs | 3 +- .../AspireBlobStorageBuilder.cs | 65 +++++++++++++++++++ .../AspireBlobStorageExtensions.cs | 12 ++-- 4 files changed, 100 insertions(+), 36 deletions(-) create mode 100644 src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 9c76e008acb..425bcd62590 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -8,9 +8,9 @@ builder.AddServiceDefaults(); -builder.AddAzureBlobClient("blobs"); -//builder.AddAzureBlobContainerClient("foocontainer"); -builder.AddKeyedAzureBlobContainerClient("mycontainer2"); +builder.AddAzureBlobClient("blobs") + .AddKeyedAzureBlobContainerClient(blobContainerName: "test-container-1") + .AddKeyedAzureBlobContainerClient(blobContainerName: "test-container-2"); builder.AddKeyedAzureBlobContainerClient("foocontainer"); builder.AddAzureQueueClient("queues"); @@ -19,46 +19,32 @@ app.MapDefaultEndpoints(); -app.MapGet("/", async ([FromKeyedServices("mycontainer2")] BlobContainerClient keyedContainerClinet1, - [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClinet2) => +app.MapGet("/", async ([FromKeyedServices("test-container-1")] BlobContainerClient keyedContainerClient1, + [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClient2) => { var blobNames = new List(); var blobNameAndContent = Guid.NewGuid().ToString(); - await keyedContainerClinet1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - await keyedContainerClinet2.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + await keyedContainerClient1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); + await keyedContainerClient2.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - var blobs = keyedContainerClinet1.GetBlobsAsync(); - blobNames.Add(keyedContainerClinet1.Uri.ToString()); - await foreach (var blob in blobs) - { - blobNames.Add(blob.Name); - } - - blobs = keyedContainerClinet2.GetBlobsAsync(); - blobNames.Add(keyedContainerClinet2.Uri.ToString()); - await foreach (var blob in blobs) - { - blobNames.Add(blob.Name); - } + await ReadBlobsAsync(keyedContainerClient1, blobNames); + await ReadBlobsAsync(keyedContainerClient2, blobNames); return blobNames; }); -app.MapGet("/test", async (BlobServiceClient bsc, QueueServiceClient qsc, BlobContainerClient fooContainerClinet) => +app.MapGet("/test", async (BlobServiceClient bsc, QueueServiceClient qsc, [FromKeyedServices("test-container-2")] BlobContainerClient keyedContainerClient1) => { + var blobNames = new List(); var blobNameAndContent = Guid.NewGuid().ToString(); - var container = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); - await container.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - - var blobs = container.GetBlobsAsync(); + await keyedContainerClient1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - var blobNames = new List(); + var directContainerClient = bsc.GetBlobContainerClient(blobContainerName: "test-container-1"); + await directContainerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - await foreach (var blob in blobs) - { - blobNames.Add(blob.Name); - } + await ReadBlobsAsync(directContainerClient, blobNames); + await ReadBlobsAsync(keyedContainerClient1, blobNames); var queue = qsc.GetQueueClient("myqueue"); await queue.CreateIfNotExistsAsync(); @@ -68,3 +54,13 @@ }); app.Run(); + +static async Task ReadBlobsAsync(BlobContainerClient containerClient, List output) +{ + output.Add(containerClient.Uri.ToString()); + var blobs = containerClient.GetBlobsAsync(); + await foreach (var blob in blobs) + { + output.Add(blob.Name); + } +} diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs index 351a152ce9a..aba1f9e6eea 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.AppHost/Program.cs @@ -10,7 +10,7 @@ var blobs = storage.AddBlobs("blobs"); blobs.AddBlobContainer("mycontainer1", blobContainerName: "test-container-1"); -var blobContainer1 = blobs.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); +blobs.AddBlobContainer("mycontainer2", blobContainerName: "test-container-2"); var queues = storage.AddQueues("queues"); @@ -24,7 +24,6 @@ builder.AddProject("api") .WithExternalHttpEndpoints() .WithReference(blobs).WaitFor(blobs) - .WithReference(blobContainer1).WaitFor(blobContainer1) .WithReference(blobContainer2).WaitFor(blobContainer2) .WithReference(queues).WaitFor(queues); diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs new file mode 100644 index 00000000000..b376f1573b1 --- /dev/null +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs @@ -0,0 +1,65 @@ +// 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.Blobs; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Represents a builder that can be used to register multiple blob container +/// instances against the same Azure Blob Storage connection. +/// +public sealed class AspireBlobStorageBuilder( + IHostApplicationBuilder builder, + string connectionName, + string? serviceKey, + AzureStorageBlobsSettings settings) +{ + /// + /// Registers as a singleton in the services provided by the parent Azure Blob Storage client. + /// Enables retries, corresponding health check, logging and telemetry. + /// + /// + /// The name of the blob container, which is used as the of the service and also to retrieve + /// the connection string from the ConnectionStrings configuration section. + /// + /// + /// Registered parent has different connection string. + /// + public AspireBlobStorageBuilder AddKeyedAzureBlobContainerClient(string blobContainerName) + { + ArgumentException.ThrowIfNullOrEmpty(blobContainerName); + + var rawConnectionString = builder.Configuration.GetConnectionString(connectionName); + ArgumentException.ThrowIfNullOrEmpty(rawConnectionString); + ((IConnectionStringSettings)settings).ParseConnectionString(rawConnectionString); + + // Note: the connection string validation is already performed when this builder was constructed. + string connectionString = settings.ConnectionString!; + + builder.Services.AddKeyedSingleton(blobContainerName, (sp, _) => + { + var blobServiceClient = string.IsNullOrWhiteSpace(serviceKey) + ? sp.GetRequiredService() + : sp.GetRequiredKeyedService(serviceKey); + + if ((!string.IsNullOrEmpty(connectionString) && + !connectionString.Contains(blobServiceClient.Uri.OriginalString, StringComparison.InvariantCultureIgnoreCase)) + || + (settings.ServiceUri is not null && + !settings.ServiceUri.AbsolutePath.Equals(blobServiceClient.Uri.AbsolutePath, StringComparison.InvariantCultureIgnoreCase))) + { + throw new InvalidOperationException($"BlobServiceClient incorrectly registered."); + } + + var containerClient = blobServiceClient.GetBlobContainerClient(blobContainerName); + return containerClient; + }); + + return this; + } +} diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs index 0669e3d23ea..bf9bd4b4c73 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs @@ -33,7 +33,7 @@ public static partial class AspireBlobStorageExtensions /// /// Neither nor is provided. /// - public static void AddAzureBlobClient( + public static AspireBlobStorageBuilder AddAzureBlobClient( this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null, @@ -42,7 +42,9 @@ public static void AddAzureBlobClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(connectionName); - new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + var settings = new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + + return new AspireBlobStorageBuilder(builder, connectionName, serviceKey: null, settings); } /// @@ -65,7 +67,7 @@ public static void AddAzureBlobClient( /// /// Neither nor is provided. /// - public static void AddKeyedAzureBlobClient( + public static AspireBlobStorageBuilder AddKeyedAzureBlobClient( this IHostApplicationBuilder builder, string name, Action? configureSettings = null, @@ -74,7 +76,9 @@ public static void AddKeyedAzureBlobClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + var settings = new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + + return new AspireBlobStorageBuilder(builder, connectionName: name, serviceKey: name, settings); } /// From 20e71f8dc0a188178290e0eac884f0eb636400d2 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Thu, 1 May 2025 16:50:01 +1000 Subject: [PATCH 08/14] fixup! AzureStorage auto create blob containers --- .../AzureBlobStorageContainerResource.cs | 1 - .../AspireBlobStorageExtensions.BlobStorageContainerComponent.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs index bd9e6148dbf..7cfb054dba1 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs @@ -19,7 +19,6 @@ public class AzureBlobStorageContainerResource(string name, string blobContainer IResourceWithConnectionString, IResourceWithParent { - /// /// Gets the blob container name. /// diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs index b6fc3deb45a..d02dd8f2c43 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs @@ -39,7 +39,6 @@ protected override IAzureClientBuilder A var containerClient = blobServiceClient.GetBlobContainerClient(settings.BlobContainerName); return containerClient; - }, requiresCredential: false); } From 8ed57ef485c3ae9a552504bc711379f095d9bb3e Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Fri, 2 May 2025 08:39:18 +1000 Subject: [PATCH 09/14] fixup! AzureStorage auto create blob containers --- .../AzureBlobStorageContainerResource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs index 7cfb054dba1..42f21a482d3 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerResource.cs @@ -40,7 +40,7 @@ public class AzureBlobStorageContainerResource(string name, string blobContainer /// A instance. internal global::Azure.Provisioning.Storage.BlobContainer ToProvisioningEntity() { - global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(BlobContainerName)) + global::Azure.Provisioning.Storage.BlobContainer blobContainer = new(Infrastructure.NormalizeBicepIdentifier(Name)) { Name = BlobContainerName }; From 83ac11641d4b1323455b6909acb8de4df9584fa4 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Mon, 5 May 2025 12:46:03 +1000 Subject: [PATCH 10/14] fixup! AzureStorage auto create blob containers --- .../AzureBlobStorageResource.cs | 2 -- .../AzureStorageExtensions.cs | 22 ++++++------------- .../AzureStorageResource.cs | 2 +- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index 80c39359ed7..483a6f2244b 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -16,8 +16,6 @@ public class AzureBlobStorageResource(string name, AzureStorageResource storage) IResourceWithParent, IResourceWithAzureFunctionsConfig { - internal List BlobContainers { get; } = []; - /// /// Gets the parent AzureStorageResource of this AzureBlobStorageResource. /// diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs index 78da68427d0..3c8e02cdb0d 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs @@ -83,14 +83,11 @@ public static IResourceBuilder AddAzureStorage(this IDistr var azureResource = (AzureStorageResource)infrastructure.AspireResource; - foreach (var blobStorageResources in azureResource.Blobs) + foreach (var blobContainer in azureResource.BlobContainers) { - foreach (var blobContainer in blobStorageResources.BlobContainers) - { - var cdkBlobContainer = blobContainer.ToProvisioningEntity(); - cdkBlobContainer.Parent = blobs; - infrastructure.Add(cdkBlobContainer); - } + var cdkBlobContainer = blobContainer.ToProvisioningEntity(); + cdkBlobContainer.Parent = blobs; + infrastructure.Add(cdkBlobContainer); } // We need to output name to externalize role assignments. @@ -159,12 +156,9 @@ public static IResourceBuilder RunAsEmulator(this IResourc throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{builder.Resource.Name}' resource but the connection string was null."); } - foreach (var blobStorageResources in builder.Resource.Blobs) + foreach (var blobContainer in builder.Resource.BlobContainers) { - foreach (var blobContainer in blobStorageResources.BlobContainers) - { - await blobServiceClient.GetBlobContainerClient(blobContainer.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); - } + await blobServiceClient.GetBlobContainerClient(blobContainer.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); } }); @@ -314,8 +308,6 @@ public static IResourceBuilder AddBlobs(this IResource ArgumentException.ThrowIfNullOrEmpty(name); var resource = new AzureBlobStorageResource(name, builder.Resource); - builder.Resource.Blobs.Add(resource); - return builder.ApplicationBuilder.AddResource(resource); } @@ -335,7 +327,7 @@ public static IResourceBuilder AddBlobContain AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource); - builder.Resource.BlobContainers.Add(resource); + builder.Resource.Parent.BlobContainers.Add(resource); return builder.ApplicationBuilder.AddResource(resource); } diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs index 3b4d3acb1d9..e5f581a1a48 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageResource.cs @@ -23,7 +23,7 @@ public class AzureStorageResource(string name, Action new(this, "queue"); private EndpointReference EmulatorTableEndpoint => new(this, "table"); - internal List Blobs { get; } = []; + internal List BlobContainers { get; } = []; /// /// Gets the "blobEndpoint" output reference from the bicep template for the Azure Storage resource. From 4112b7315235e8e0975e66e7eba584c3dcca6811 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Mon, 5 May 2025 14:11:39 +1000 Subject: [PATCH 11/14] Test AF integration --- .../AzureFunctionsEndToEnd.AppHost/Program.cs | 3 ++ .../MyAzureBlobTrigger.cs | 11 ++-- .../Program.cs | 1 + .../AzureBlobStorageContainerSettings.cs | 53 ++++++++----------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs index 669ad2c9956..e52a06b7427 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs @@ -3,6 +3,8 @@ var storage = builder.AddAzureStorage("storage").RunAsEmulator(); var queue = storage.AddQueues("queue"); var blob = storage.AddBlobs("blob"); +var myBlobContainer = blob.AddBlobContainer("myblobcontainer"); + var eventHub = builder.AddAzureEventHubs("eventhubs") .RunAsEmulator() .AddHub("myhub"); @@ -20,6 +22,7 @@ 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) diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs index f8a2a5ef5d0..6c535261cdd 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyAzureBlobTrigger.cs @@ -1,16 +1,19 @@ +using Azure.Storage.Blobs; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; namespace AzureFunctionsEndToEnd.Functions; -public class MyAzureBlobTrigger(ILogger logger) +public class MyAzureBlobTrigger(ILogger logger, BlobContainerClient containerClient) { [Function(nameof(MyAzureBlobTrigger))] [BlobOutput("test-files/{name}.txt", Connection = "blob")] - public string Run([BlobTrigger("blobs/{name}", Connection = "blob")] string triggerString) + public async Task RunAsync([BlobTrigger("blobs/{name}", Connection = "blob")] string triggerString, FunctionContext context) { - logger.LogInformation("C# blob trigger function invoked with {message}...", triggerString); + var blobName = (string)context.BindingContext.BindingData["name"]!; + await containerClient.UploadBlobAsync(blobName, new BinaryData(triggerString)); + + logger.LogInformation("C# blob trigger function invoked for 'blobs/{source}' with {message}...", blobName, triggerString); return triggerString.ToUpper(); } } - diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs index e2d490b1c58..336749046b4 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/Program.cs @@ -6,6 +6,7 @@ builder.AddServiceDefaults(); builder.AddAzureQueueClient("queue"); builder.AddAzureBlobClient("blob"); +builder.AddAzureBlobContainerClient("myblobcontainer"); builder.AddAzureEventHubProducerClient("myhub"); #if !SKIP_UNSTABLE_EMULATORS builder.AddAzureServiceBusClient("messaging"); diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs index 2eb3530c8c9..85456a34e73 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs @@ -1,9 +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.Data.Common; -using System.Globalization; -using System.Text; +using System.Text.RegularExpressions; using Aspire.Azure.Common; namespace Aspire.Azure.Storage.Blobs; @@ -11,8 +9,11 @@ namespace Aspire.Azure.Storage.Blobs; /// /// Provides the client configuration settings for connecting to Azure Blob Storage container. /// -public sealed class AzureBlobStorageContainerSettings : AzureStorageBlobsSettings, IConnectionStringSettings +public sealed partial class AzureBlobStorageContainerSettings : AzureStorageBlobsSettings, IConnectionStringSettings { + [GeneratedRegex(@"ContainerName=([^;]*);")] + private static partial Regex ExtractContainerName(); + public string? BlobContainerName { get; set; } void IConnectionStringSettings.ParseConnectionString(string? connectionString) @@ -22,38 +23,30 @@ void IConnectionStringSettings.ParseConnectionString(string? connectionString) return; } + // In the emulator mode, the connection string may look like: + // + // DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=...;BlobEndpoint=http://127.0.0.1:5555/devstoreaccount1;ContainerName=; + // + // When run against the real Azure resources, the connection string will look similar to: + // + // https://.blob.core.windows.net/ContainerName=; + // + // Retrieve the container name from the connection string, if it is present; and then + // remove it as it will upset BlobServiceClient. + + if (ExtractContainerName().Match(connectionString) is var match) + { + BlobContainerName = match.Groups[1].Value; + connectionString = connectionString.Replace(match.Value, string.Empty); + } + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) { ServiceUri = uri; - - // TODO: how do we get the container name from the URI? } else { - var connectionBuilder = new DbConnectionStringBuilder() - { - ConnectionString = connectionString - }; - - if (connectionBuilder.TryGetValue("ContainerName", out var containerValue)) - { - BlobContainerName = (string)containerValue; - - // Remove it from the connection string, it is our custom property. - connectionBuilder["ContainerName"] = null; - } - - // We can't use connectionBuilder.ConnectionString here, because connectionBuilder escapes values - // adding quotes and other characters, which upsets the Azure SDK. - // So, we have rebuilt the connection string manually. - - StringBuilder builder = new(); - foreach (string keyword in connectionBuilder.Keys) - { - builder.Append(CultureInfo.InvariantCulture, $"{keyword}={connectionBuilder[keyword]};"); - } - - ConnectionString = builder.ToString(); + ConnectionString = connectionString; } } } From 05a09212e3a70ec9d6c11da23b1e7563436251be Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Tue, 6 May 2025 10:22:12 +1000 Subject: [PATCH 12/14] Use Endpoint for connection string --- Aspire.sln | 2 -- .../AzureBlobStorageResource.cs | 10 ++++-- .../AzureStorageEmulatorConnectionString.cs | 10 +++--- .../AzureBlobStorageContainerSettings.cs | 36 ++++++------------- 4 files changed, 23 insertions(+), 35 deletions(-) diff --git a/Aspire.sln b/Aspire.sln index b6abcb7249a..be3505999da 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -661,8 +661,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql.EntityF EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Components.Common.Tests", "tests\Aspire.Components.Common.Tests\Aspire.Components.Common.Tests.csproj", "{30950CEB-2232-F9FC-04FF-ADDCB8AC30A7}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.ContainerRegistry", "src\Aspire.Hosting.Azure.ContainerRegistry\Aspire.Hosting.Azure.ContainerRegistry.csproj", "{6CBA29C8-FF78-4ABC-BEFA-2A53CB4DB2A3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Azure.AppService", "src\Aspire.Hosting.Azure.AppService\Aspire.Hosting.Azure.AppService.csproj", "{5DDF8E89-FBBD-4A6F-BF32-7D2140724941}" diff --git a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs index 483a6f2244b..31b51666133 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureBlobStorageResource.cs @@ -16,6 +16,10 @@ public class AzureBlobStorageResource(string name, AzureStorageResource storage) IResourceWithParent, IResourceWithAzureFunctionsConfig { + // NOTE: if ever these contants are changed, the AzureBlobStorageContainerSettings in Aspire.Azure.Storage.Blobs class should be updated as well. + private const string Endpoint = nameof(Endpoint); + private const string ContainerName = nameof(ContainerName); + /// /// Gets the parent AzureStorageResource of this AzureBlobStorageResource. /// @@ -34,12 +38,12 @@ internal ReferenceExpression GetConnectionString(string? blobContainerName) return ConnectionStringExpression; } - var builder = new ReferenceExpressionBuilder(); - builder.Append($"{ConnectionStringExpression}"); + ReferenceExpressionBuilder builder = new(); + builder.Append($"{Endpoint}=\"{ConnectionStringExpression}\";"); if (!string.IsNullOrEmpty(blobContainerName)) { - builder.Append($"ContainerName={blobContainerName};"); + builder.Append($"{ContainerName}={blobContainerName};"); } return builder.Build(); diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs index ab522600a8c..c8e3a2c4eee 100644 --- a/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs +++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageEmulatorConnectionString.cs @@ -10,11 +10,6 @@ internal static class AzureStorageEmulatorConnectionString // Use defaults from https://learn.microsoft.com/azure/storage/common/storage-configure-connection-string#connect-to-the-emulator-account-using-the-shortcut private const string ConnectionStringHeader = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;"; - private static void AppendEndpointExpression(ReferenceExpressionBuilder builder, string key, EndpointReference endpoint) - { - builder.Append($"{key}=http://{endpoint.Property(EndpointProperty.IPV4Host)}:{endpoint.Property(EndpointProperty.Port)}/devstoreaccount1;"); - } - public static ReferenceExpression Create(EndpointReference? blobEndpoint = null, EndpointReference? queueEndpoint = null, EndpointReference? tableEndpoint = null) { var builder = new ReferenceExpressionBuilder(); @@ -34,5 +29,10 @@ public static ReferenceExpression Create(EndpointReference? blobEndpoint = null, } return builder.Build(); + + static void AppendEndpointExpression(ReferenceExpressionBuilder builder, string key, EndpointReference endpoint) + { + builder.Append($"{key}=http://{endpoint.Property(EndpointProperty.IPV4Host)}:{endpoint.Property(EndpointProperty.Port)}/devstoreaccount1;"); + } } } diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs index 85456a34e73..3db7147d78f 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AzureBlobStorageContainerSettings.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.Data.Common; using System.Text.RegularExpressions; using Aspire.Azure.Common; @@ -11,9 +12,9 @@ namespace Aspire.Azure.Storage.Blobs; /// public sealed partial class AzureBlobStorageContainerSettings : AzureStorageBlobsSettings, IConnectionStringSettings { - [GeneratedRegex(@"ContainerName=([^;]*);")] - private static partial Regex ExtractContainerName(); - + /// + /// Gets or sets the name of the blob container. + /// public string? BlobContainerName { get; set; } void IConnectionStringSettings.ParseConnectionString(string? connectionString) @@ -23,30 +24,15 @@ void IConnectionStringSettings.ParseConnectionString(string? connectionString) return; } - // In the emulator mode, the connection string may look like: - // - // DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=...;BlobEndpoint=http://127.0.0.1:5555/devstoreaccount1;ContainerName=; - // - // When run against the real Azure resources, the connection string will look similar to: - // - // https://.blob.core.windows.net/ContainerName=; - // - // Retrieve the container name from the connection string, if it is present; and then - // remove it as it will upset BlobServiceClient. - - if (ExtractContainerName().Match(connectionString) is var match) - { - BlobContainerName = match.Groups[1].Value; - connectionString = connectionString.Replace(match.Value, string.Empty); - } + // NOTE: if ever these contants are changed, the AzureBlobStorageResource in Aspire.Hosting.Azure.Storage class should be updated as well. + const string Endpoint = nameof(Endpoint); + const string ContainerName = nameof(ContainerName); - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) - { - ServiceUri = uri; - } - else + DbConnectionStringBuilder builder = new() { ConnectionString = connectionString }; + if (builder.TryGetValue(Endpoint, out var endpoint) && builder.TryGetValue(ContainerName, out var containerName)) { - ConnectionString = connectionString; + ConnectionString = endpoint.ToString(); + BlobContainerName = containerName.ToString(); } } } From 36edcda961ca6534eb2652f6882d552d9c5c8cbf Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Tue, 6 May 2025 11:07:40 +1000 Subject: [PATCH 13/14] Revert AspireBlobStorageBuilder --- .../Program.cs | 20 +----- .../AspireBlobStorageBuilder.cs | 65 ------------------- .../AspireBlobStorageExtensions.cs | 12 ++-- 3 files changed, 6 insertions(+), 91 deletions(-) delete mode 100644 src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs diff --git a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs index 425bcd62590..02f16b8f516 100644 --- a/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs +++ b/playground/AzureStorageEndToEnd/AzureStorageEndToEnd.ApiService/Program.cs @@ -8,9 +8,7 @@ builder.AddServiceDefaults(); -builder.AddAzureBlobClient("blobs") - .AddKeyedAzureBlobContainerClient(blobContainerName: "test-container-1") - .AddKeyedAzureBlobContainerClient(blobContainerName: "test-container-2"); +builder.AddAzureBlobClient("blobs"); builder.AddKeyedAzureBlobContainerClient("foocontainer"); builder.AddAzureQueueClient("queues"); @@ -19,21 +17,7 @@ app.MapDefaultEndpoints(); -app.MapGet("/", async ([FromKeyedServices("test-container-1")] BlobContainerClient keyedContainerClient1, - [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClient2) => -{ - var blobNames = new List(); - var blobNameAndContent = Guid.NewGuid().ToString(); - - await keyedContainerClient1.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - await keyedContainerClient2.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); - - await ReadBlobsAsync(keyedContainerClient1, blobNames); - await ReadBlobsAsync(keyedContainerClient2, blobNames); - - return blobNames; -}); -app.MapGet("/test", async (BlobServiceClient bsc, QueueServiceClient qsc, [FromKeyedServices("test-container-2")] BlobContainerClient keyedContainerClient1) => +app.MapGet("/", async (BlobServiceClient bsc, QueueServiceClient qsc, [FromKeyedServices("foocontainer")] BlobContainerClient keyedContainerClient1) => { var blobNames = new List(); var blobNameAndContent = Guid.NewGuid().ToString(); diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs deleted file mode 100644 index b376f1573b1..00000000000 --- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageBuilder.cs +++ /dev/null @@ -1,65 +0,0 @@ -// 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.Blobs; -using Azure.Storage.Blobs; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Extensions.Hosting; - -/// -/// Represents a builder that can be used to register multiple blob container -/// instances against the same Azure Blob Storage connection. -/// -public sealed class AspireBlobStorageBuilder( - IHostApplicationBuilder builder, - string connectionName, - string? serviceKey, - AzureStorageBlobsSettings settings) -{ - /// - /// Registers as a singleton in the services provided by the parent Azure Blob Storage client. - /// Enables retries, corresponding health check, logging and telemetry. - /// - /// - /// The name of the blob container, which is used as the of the service and also to retrieve - /// the connection string from the ConnectionStrings configuration section. - /// - /// - /// Registered parent has different connection string. - /// - public AspireBlobStorageBuilder AddKeyedAzureBlobContainerClient(string blobContainerName) - { - ArgumentException.ThrowIfNullOrEmpty(blobContainerName); - - var rawConnectionString = builder.Configuration.GetConnectionString(connectionName); - ArgumentException.ThrowIfNullOrEmpty(rawConnectionString); - ((IConnectionStringSettings)settings).ParseConnectionString(rawConnectionString); - - // Note: the connection string validation is already performed when this builder was constructed. - string connectionString = settings.ConnectionString!; - - builder.Services.AddKeyedSingleton(blobContainerName, (sp, _) => - { - var blobServiceClient = string.IsNullOrWhiteSpace(serviceKey) - ? sp.GetRequiredService() - : sp.GetRequiredKeyedService(serviceKey); - - if ((!string.IsNullOrEmpty(connectionString) && - !connectionString.Contains(blobServiceClient.Uri.OriginalString, StringComparison.InvariantCultureIgnoreCase)) - || - (settings.ServiceUri is not null && - !settings.ServiceUri.AbsolutePath.Equals(blobServiceClient.Uri.AbsolutePath, StringComparison.InvariantCultureIgnoreCase))) - { - throw new InvalidOperationException($"BlobServiceClient incorrectly registered."); - } - - var containerClient = blobServiceClient.GetBlobContainerClient(blobContainerName); - return containerClient; - }); - - return this; - } -} diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs index bf9bd4b4c73..0669e3d23ea 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.cs @@ -33,7 +33,7 @@ public static partial class AspireBlobStorageExtensions /// /// Neither nor is provided. /// - public static AspireBlobStorageBuilder AddAzureBlobClient( + public static void AddAzureBlobClient( this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null, @@ -42,9 +42,7 @@ public static AspireBlobStorageBuilder AddAzureBlobClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(connectionName); - var settings = new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); - - return new AspireBlobStorageBuilder(builder, connectionName, serviceKey: null, settings); + new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); } /// @@ -67,7 +65,7 @@ public static AspireBlobStorageBuilder AddAzureBlobClient( /// /// Neither nor is provided. /// - public static AspireBlobStorageBuilder AddKeyedAzureBlobClient( + public static void AddKeyedAzureBlobClient( this IHostApplicationBuilder builder, string name, Action? configureSettings = null, @@ -76,9 +74,7 @@ public static AspireBlobStorageBuilder AddKeyedAzureBlobClient( ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - var settings = new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); - - return new AspireBlobStorageBuilder(builder, connectionName: name, serviceKey: name, settings); + new BlobStorageComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); } /// From ab1c8a5e3dc06c0ef9ec60e5b04746752b83df22 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Tue, 6 May 2025 13:36:12 +1000 Subject: [PATCH 14/14] Tests --- .github/copilot-instructions.md | 33 ++++++ .../MyHttpTrigger.cs | 4 +- .../AssemblyInfo.cs | 3 + .../AzureBlobStorageContainerSettingsTests.cs | 53 +++++++++ .../ConformanceTests.cs | 3 + .../AzureStorageEmulatorFunctionalTests.cs | 47 +++++++- .../AzureStorageExtensionsTests.cs | 109 +++++++++++++++++- ...sts.ResourceNamesBicepValid.verified.bicep | 2 +- .../ProjectSpecificTests.cs | 22 +++- 9 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 tests/Aspire.Azure.Storage.Blobs.Tests/AzureBlobStorageContainerSettingsTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..259dfb1fd93 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,33 @@ +# Instructions for GitHub and VisualStudio Copilot +### https://github.blog/changelog/2025-01-21-custom-repository-instructions-are-now-available-for-copilot-on-github-com-public-preview/ + + +## General + +* Make only high confidence suggestions when reviewing code changes. +* Always use the latest version C#, currently C# 13 features. +* Files must have CRLF line endings. + +## Formatting + +* Apply code-formatting style defined in `.editorconfig`. +* Prefer file-scoped namespace declarations and single-line using directives. +* Insert a newline before the opening curly brace of any code block (e.g., after `if`, `for`, `while`, `foreach`, `using`, `try`, etc.). +* Ensure that the final return statement of a method is on its own line. +* Use pattern matching and switch expressions wherever possible. +* Use `nameof` instead of string literals when referring to member names. + +### Nullable Reference Types + +* Declare variables non-nullable, and check for `null` at entry points. +* Always use `is null` or `is not null` instead of `== null` or `!= null`. +* Trust the C# null annotations and don't add null checks when the type system says a value cannot be null. + + +### Testing + +* We use xUnit SDK v3 with Microsoft.Testing.Platform (https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-intro) +* Do not emit "Act", "Arrange" or "Assert" comments. +* We do not use any mocking framework at the moment. Use NSubstitute, if necessary. Never use Moq. +* Use "snake_case" for test method names but keep the original method under test intact. + For example: when adding a test for methond "MethondToTest" instead of "MethondToTest_ShouldReturnSummarisedIssues" use "MethondToTest_should_return_summarised_issues". diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs index dda47d7e73c..83fc3a38893 100644 --- a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs +++ b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.Functions/MyHttpTrigger.cs @@ -22,7 +22,8 @@ public class MyHttpTrigger( #endif EventHubProducerClient eventHubProducerClient, QueueServiceClient queueServiceClient, - BlobServiceClient blobServiceClient) + BlobServiceClient blobServiceClient, + BlobContainerClient blobContainerClient) { [Function("injected-resources")] public IResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req) @@ -35,6 +36,7 @@ public IResult Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] Ht 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 BlobServiceClient URI: {blobServiceClient.Uri}"); + stringBuilder.AppendLine(CultureInfo.InvariantCulture, $"Aspire-injected BlobContainerClient URI: {blobContainerClient.Uri}"); return Results.Text(stringBuilder.ToString()); } } diff --git a/src/Components/Aspire.Azure.Storage.Blobs/AssemblyInfo.cs b/src/Components/Aspire.Azure.Storage.Blobs/AssemblyInfo.cs index 524efad0cfe..6a96ebe1ece 100644 --- a/src/Components/Aspire.Azure.Storage.Blobs/AssemblyInfo.cs +++ b/src/Components/Aspire.Azure.Storage.Blobs/AssemblyInfo.cs @@ -4,6 +4,7 @@ using Aspire.Azure.Storage.Blobs; using Aspire; using Azure.Storage.Blobs; +using System.Runtime.CompilerServices; [assembly: ConfigurationSchema("Aspire:Azure:Storage:Blobs", typeof(AzureStorageBlobsSettings))] [assembly: ConfigurationSchema("Aspire:Azure:Storage:Blobs:ClientOptions", typeof(BlobClientOptions), exclusionPaths: ["Default"])] @@ -12,3 +13,5 @@ "Azure", "Azure.Core", "Azure.Identity")] + +[assembly: InternalsVisibleTo("Aspire.Azure.Storage.Blobs.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001004b86c4cb78549b34bab61a3b1800e23bfeb5b3ec390074041536a7e3cbd97f5f04cf0f857155a8928eaa29ebfd11cfbbad3ba70efea7bda3226c6a8d370a4cd303f714486b6ebc225985a638471e6ef571cc92a4613c00b8fa65d61ccee0cbe5f36330c9a01f4183559f1bef24cc2917c6d913e3a541333a1d05d9bed22b38cb")] diff --git a/tests/Aspire.Azure.Storage.Blobs.Tests/AzureBlobStorageContainerSettingsTests.cs b/tests/Aspire.Azure.Storage.Blobs.Tests/AzureBlobStorageContainerSettingsTests.cs new file mode 100644 index 00000000000..c7283c7fc69 --- /dev/null +++ b/tests/Aspire.Azure.Storage.Blobs.Tests/AzureBlobStorageContainerSettingsTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Configuration; +using Aspire.Azure.Common; +using Aspire.Azure.Storage.Blobs; +using Xunit; + +namespace Aspire.Hosting.Azure.Tests; + +public class AzureBlobStorageContainerSettingsTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(";")] + [InlineData("Endpoint=https://example.blob.core.windows.net;")] + [InlineData("ContainerName=my-container;")] + [InlineData("Endpoint=https://example.blob.core.windows.net;ExtraParam=value;")] + public void ParseConnectionString_invalid_input(string? connectionString) + { + var settings = new AzureBlobStorageContainerSettings(); + + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + + Assert.Null(settings.ConnectionString); + Assert.Null(settings.BlobContainerName); + } + + [Fact] + public void ParseConnectionString_invalid_input_results_in_AE() + { + var settings = new AzureBlobStorageContainerSettings(); + string connectionString = "InvalidConnectionString"; + + Assert.Throws(() => ((IConnectionStringSettings)settings).ParseConnectionString(connectionString)); + } + + [Theory] + [InlineData("Endpoint=https://example.blob.core.windows.net;ContainerName=my-container")] + [InlineData("Endpoint=https://example.blob.core.windows.net;ContainerName=my-container;ExtraParam=value")] + [InlineData("endpoint=https://example.blob.core.windows.net;containername=my-container")] + [InlineData("ENDPOINT=https://example.blob.core.windows.net;CONTAINERNAME=my-container")] + public void ParseConnectionString_valid_input(string connectionString) + { + var settings = new AzureBlobStorageContainerSettings(); + + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + + Assert.Equal("https://example.blob.core.windows.net", settings.ConnectionString); + Assert.Equal("my-container", settings.BlobContainerName); + } +} diff --git a/tests/Aspire.Azure.Storage.Blobs.Tests/ConformanceTests.cs b/tests/Aspire.Azure.Storage.Blobs.Tests/ConformanceTests.cs index a843ce82332..a53c74a5541 100644 --- a/tests/Aspire.Azure.Storage.Blobs.Tests/ConformanceTests.cs +++ b/tests/Aspire.Azure.Storage.Blobs.Tests/ConformanceTests.cs @@ -24,6 +24,9 @@ public class ConformanceTests : ConformanceTests "Azure.Storage.Blobs.BlobContainerClient"; + // AzureStorageBlobsSettings subclassed by AzureBlobStorageContainerSettings + protected override bool CheckOptionClassSealed => false; + protected override string[] RequiredLogCategories => new string[] { "Azure.Core", diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs index ef68d732912..756d0011a2e 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs @@ -62,6 +62,50 @@ public async Task VerifyWaitForOnAzureStorageEmulatorForBlobsBlocksDependentReso await app.StopAsync(); } + [Fact] + [RequiresDocker] + public async Task VerifyWaitForOnAzureStorageEmulatorForBlobContainersBlocksDependentResources() + { + 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 blobs = storage.AddBlobs("blobs"); + var blobContainer = blobs.AddBlobContainer("testblobcontainer"); + + var dependentResource = builder.AddContainer("nginx", "mcr.microsoft.com/cbl-mariner/base/nginx", "1.22") + .WaitFor(blobContainer); + + 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(blobContainer.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() @@ -91,7 +135,7 @@ public async Task VerifyAzureStorageEmulatorResource() [Fact] [RequiresDocker] - public async Task VerifyAzureStorageEmulatorBlobContainer() + public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper); var storage = builder.AddAzureStorage("storage").RunAsEmulator(); @@ -115,6 +159,7 @@ public async Task VerifyAzureStorageEmulatorBlobContainer() var blobContainerClient = serviceClient.GetBlobContainerClient("testblobcontainer"); var exists = await blobContainerClient.ExistsAsync(); + Assert.True(exists, "Blob container should exist after starting the application."); var blobNameAndContent = Guid.NewGuid().ToString(); var response = await blobContainerClient.UploadBlobAsync(blobNameAndContent, new BinaryData(blobNameAndContent)); diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs index 89a489939b2..099d744fc42 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageExtensionsTests.cs @@ -164,6 +164,111 @@ public async Task AddAzureStorage_RunAsEmulator_SetSkipApiVersionCheck() Assert.Contains("--skipApiVersionCheck", args); } + [Fact] + public async Task AddBlobs_ConnectionString_resolved_expected_RunAsEmulator() + { + const string expected = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/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 blobs = storage.AddBlobs("blob"); + + Assert.Equal(expected, await ((IResourceWithConnectionString)blobs.Resource).GetConnectionStringAsync()); + } + + [Fact] + public async Task AddBlobs_ConnectionString_resolved_expected() + { + const string blobsConnectionString = "https://myblob"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var storagesku = builder.AddParameter("storagesku"); + var storage = builder.AddAzureStorage("storage"); + storage.Resource.Outputs["blobEndpoint"] = blobsConnectionString; + + var blobs = storage.AddBlobs("blob"); + + Assert.Equal(blobsConnectionString, await ((IResourceWithConnectionString)blobs.Resource).GetConnectionStringAsync()); + } + + [Fact] + public void AddBlobs_ConnectionString_unresolved_expected() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blob"); + + Assert.Equal("{storage.outputs.blobEndpoint}", blobs.Resource.ConnectionStringExpression.ValueExpression); + } + + [Fact] + public async Task AddBlobContainer_ConnectionString_resolved_expected_RunAsEmulator() + { + const string blobContainerName = "my-blob-container"; + + 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 blobs = storage.AddBlobs("blob"); + var blobContainer = blobs.AddBlobContainer(name: "myContainer", blobContainerName); + + string? blobConntionString = await ((IResourceWithConnectionString)blobs.Resource).GetConnectionStringAsync(); + string expected = $"Endpoint=\"{blobConntionString}\";ContainerName={blobContainerName};"; + + Assert.Equal(expected, await ((IResourceWithConnectionString)blobContainer.Resource).GetConnectionStringAsync()); + } + + [Fact] + public async Task AddBlobContainer_ConnectionString_resolved_expected() + { + const string blobContainerName = "my-blob-container"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var storagesku = builder.AddParameter("storagesku"); + var storage = builder.AddAzureStorage("storage"); + storage.Resource.Outputs["blobEndpoint"] = "https://myblob"; + + var blobs = storage.AddBlobs("blob"); + var blobContainer = blobs.AddBlobContainer(name: "myContainer", blobContainerName); + + string? blobsConnectionString = await ((IResourceWithConnectionString)blobs.Resource).GetConnectionStringAsync(); + string expected = $"Endpoint=\"{blobsConnectionString}\";ContainerName={blobContainerName};"; + + Assert.Equal(expected, await ((IResourceWithConnectionString)blobContainer.Resource).GetConnectionStringAsync()); + } + + [Fact] + public void AddBlobContainer_ConnectionString_unresolved_expected() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var storage = builder.AddAzureStorage("storage"); + var blobs = storage.AddBlobs("blob"); + var blobContainer = blobs.AddBlobContainer(name: "myContainer"); + + Assert.Equal("Endpoint=\"{storage.outputs.blobEndpoint}\";ContainerName=myContainer;", blobContainer.Resource.ConnectionStringExpression.ValueExpression); + } + [Fact] public async Task ResourceNamesBicepValid() { @@ -171,13 +276,13 @@ public async Task ResourceNamesBicepValid() var storage = builder.AddAzureStorage("storage"); var blobs = storage.AddBlobs("myblobs"); - var blob = blobs.AddBlobContainer("myContainer", "my-blob-container"); + var blob = blobs.AddBlobContainer(name: "myContainer", blobContainerName: "my-blob-container"); var queues = storage.AddQueues("myqueues"); var tables = storage.AddTables("mytables"); var manifest = await AzureManifestUtils.GetManifestWithBicep(storage.Resource); await Verifier.Verify(manifest.BicepText, extension: "bicep") - .UseDirectory("Snapshots"); + .UseHelixAwareDirectory("Snapshots"); } } 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 940bb056d9d..c539e1e9812 100644 --- a/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep +++ b/tests/Aspire.Hosting.Azure.Tests/Snapshots/AzureStorageExtensionsTests.ResourceNamesBicepValid.verified.bicep @@ -26,7 +26,7 @@ resource blobs 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { parent: storage } -resource my_blob_container 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { +resource myContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { name: 'my-blob-container' parent: blobs } diff --git a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs b/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs index bf88dab0d67..84fd875dca2 100644 --- a/tests/Aspire.Playground.Tests/ProjectSpecificTests.cs +++ b/tests/Aspire.Playground.Tests/ProjectSpecificTests.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.Text.RegularExpressions; using Aspire.Hosting; using Aspire.Hosting.Tests.Utils; using Aspire.TestUtilities; @@ -76,7 +77,26 @@ await WaitForAllTextAsync(app, timeoutSecs: 160); // Assert that HTTP triggers work correctly - await AppHostTests.CreateHttpClientWithResilience(app, "funcapp").GetAsync("/api/injected-resources"); + var response = await AppHostTests.CreateHttpClientWithResilience(app, "funcapp").GetAsync("/api/injected-resources"); + + // The output contains multiple text lines. + // There are some URLs which contain port number, but the port numbers may vary, so we replace those for test validation. + var output = await response.Content.ReadAsStringAsync(); + output = Regex.Replace(output, pattern: @"(?<=http:\/\/127\.0\.0\.1:)\d+", replacement: "*"); + + _testOutput.WriteLine($"[DEBUG] Response:\r\n{output}"); + var expectedStrings = new string[] + { + "Aspire-injected EventHubProducerClient namespace: localhost", + "Aspire-injected QueueServiceClient URI: http://127.0.0.1:*/devstoreaccount1", + "Aspire-injected BlobServiceClient URI: http://127.0.0.1:*/devstoreaccount1", + "Aspire-injected BlobContainerClient URI: http://127.0.0.1:*/devstoreaccount1/myblobcontainer" + }; + foreach (string s in expectedStrings) + { + Assert.Contains(s, output); + } + await WaitForAllTextAsync(app, [ "Executed 'Functions.injected-resources'"