Repository: dotnet/aspire
Pull request #9159: Update health check to ensure blob containers created at right time
Author: login: RussKie
Labels: name: area-integrations
description: Issues pertaining to Aspire Integrations packages
Created: 2025-05-08T08:30:25Z | Merged: 2025-05-16T18:20:58Z



Resolves #9139
Resolves #9145

The underlying issue is attributed to a lack of health checks for child resources (as those have no lifetime of their own). In the nutshell, whenever an Azurite emulator is starting up, the readiness of the emulator is indicated by the readiness of the "blobs" resources (represented by `BloblServiceClient`). Previously, child blob contrainers were created on `ResourceReadyEvent`, but this created an opportunity for a race condition - a client could attempt to connect _after_ the resource reported healthy but _before_ child resources were created - the flaky test highlighted this problem.

To fix the issue we're now creating an individual health check for each blob container resource. To make it simple, a blob container is being created within the health check itself.

Linked issue #9139: [Failing test]: `Aspire.Hosting.Azure.Tests.AzureStorageEmulatorFunctionalTests.VerifyAzureStorageEmulator_blobcontainer_auto_created`
### Is there an existing issue for this?

- [x] I have searched the existing issues

### Build information

Build: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1035528&view=results
Build error leg or test failing: Ubuntu
Pull Request: N/A


### Fill in the error message template

_No response_

### Other info

_No response_
<!--Known issue error report start -->

### Report
#### Summary
|24-Hour Hit Count|7-Day Hit Count|1-Month Count|
|---|---|---|
|0|0|0|
<!--Known issue error report end -->
Linked issue comments: user: login: radical
created_at: 2025-05-07T19:57:47Z
body: Closing in favor of https://github.com/dotnet/aspire/issues/9145
author_association: MEMBER
user: login: radical
created_at: 2025-05-18T18:21:11Z
body: This was incorrectly re-opened instead of https://github.com/dotnet/aspire/issues/9145 .
author_association: MEMBER

Diff:
diff --git a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs
index dab40df1921..b453e2831d9 100644
--- a/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs
+++ b/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs
@@ -121,7 +121,6 @@ private static IResourceBuilder<AzureCosmosDBResource> RunAsEmulator(this IResou
             }
         });
 
-        // Use custom health check that also seeds the databases and containers
         var healthCheckKey = $"{builder.Resource.Name}_check";
         builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureCosmosDB(
             sp => cosmosClient ?? throw new InvalidOperationException("CosmosClient is not initialized."),
diff --git a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
index 3c8e02cdb0d..8c9235640b1 100644
--- a/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
+++ b/src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
@@ -95,6 +95,7 @@ public static IResourceBuilder<AzureStorageResource> AddAzureStorage(this IDistr
         };
 
         var resource = new AzureStorageResource(name, configureInfrastructure);
+
         return builder.AddResource(resource)
             .WithDefaultRoleAssignments(StorageBuiltInRole.GetBuiltInRoleName,
                 StorageBuiltInRole.StorageBlobDataContributor,
@@ -131,34 +132,30 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                });
 
         BlobServiceClient? blobServiceClient = null;
-
         builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
         {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is null)
-            {
-                throw new DistributedApplicationException($"BeforeResourceStartedEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
-            }
+            // The BlobServiceClient is created before the health check is run.
+            // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
+            // we use BeforeResourceStartedEvent
 
+            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
             blobServiceClient = CreateBlobServiceClient(connectionString);
         });
 
         builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
         {
-            if (blobServiceClient is null)
-            {
-                throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource.");
-            }
+            // The ResourceReadyEvent of a resource is triggered after its health check is healthy.
+            // This means we can safely use this event to create the blob containers.
 
-            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 InvalidOperationException("BlobServiceClient is not initialized.");
             }
 
-            foreach (var blobContainer in builder.Resource.BlobContainers)
+            foreach (var container in builder.Resource.BlobContainers)
             {
-                await blobServiceClient.GetBlobContainerClient(blobContainer.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
+                var blobContainerClient = blobServiceClient.GetBlobContainerClient(container.BlobContainerName);
+                await blobContainerClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
             }
         });
 
@@ -182,18 +179,6 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
         configureContainer?.Invoke(surrogateBuilder);
 
         return builder;
-
-        static BlobServiceClient CreateBlobServiceClient(string connectionString)
-        {
-            if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
-            {
-                return new BlobServiceClient(uri, new DefaultAzureCredential());
-            }
-            else
-            {
-                return new BlobServiceClient(connectionString);
-            }
-        }
     }
 
     /// <summary>
@@ -308,7 +293,22 @@ public static IResourceBuilder<AzureBlobStorageResource> AddBlobs(this IResource
         ArgumentException.ThrowIfNullOrEmpty(name);
 
         var resource = new AzureBlobStorageResource(name, builder.Resource);
-        return builder.ApplicationBuilder.AddResource(resource);
+
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp =>
+        {
+            return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized."));
+        }, name: healthCheckKey);
+
+        return builder.ApplicationBuilder.AddResource(resource).WithHealthCheck(healthCheckKey);
     }
 
     /// <summary>
@@ -326,10 +326,24 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.Parent.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(
+            sp => blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized.")),
+            optionsFactory: sp => new HealthChecks.Azure.Storage.Blobs.AzureBlobStorageHealthCheckOptions { ContainerName = blobContainerName },
+            name: healthCheckKey);
+
+        return builder.ApplicationBuilder
+            .AddResource(resource).WithHealthCheck(healthCheckKey);
     }
 
     /// <summary>
@@ -362,6 +376,18 @@ public static IResourceBuilder<AzureQueueStorageResource> AddQueues(this IResour
         return builder.ApplicationBuilder.AddResource(resource);
     }
 
+    private static BlobServiceClient CreateBlobServiceClient(string connectionString)
+    {
+        if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
+        {
+            return new BlobServiceClient(uri, new DefaultAzureCredential());
+        }
+        else
+        {
+            return new BlobServiceClient(connectionString);
+        }
+    }
+
     /// <summary>
     /// 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.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs
deleted file mode 100644
index 764b91a8b24..00000000000
--- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.AzureBlobStorageContainerHealthCheck.cs
+++ /dev/null
@@ -1,40 +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 Azure.Storage.Blobs;
-using Microsoft.Extensions.Diagnostics.HealthChecks;
-
-namespace Microsoft.Extensions.Hosting;
-
-partial class AspireBlobStorageExtensions
-{
-    partial class BlobStorageContainerComponent
-    {
-        /// <summary>
-        /// Azure Blob Storage container health check.
-        /// </summary>
-        /// <param name="blobContainerClient">
-        /// The <see cref="BlobContainerClient"/> used to perform Azure Blob Storage container operations.
-        /// Azure SDK recommends treating clients as singletons <see href="https://devblogs.microsoft.com/azure-sdk/lifetime-management-and-thread-safety-guarantees-of-azure-sdk-net-clients/"/>,
-        /// so this should be the exact same instance used by other parts of the application.
-        /// </param>
-        private sealed class AzureBlobStorageContainerHealthCheck(BlobContainerClient blobContainerClient) : IHealthCheck
-        {
-            private readonly BlobContainerClient _blobServiceClient = blobContainerClient ?? throw new ArgumentNullException(nameof(blobContainerClient));
-
-            /// <inheritdoc />
-            public async Task<HealthCheckResult> 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
index d02dd8f2c43..1476d73a5b2 100644
--- a/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs
+++ b/src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs
@@ -6,6 +6,8 @@
 using Azure.Core;
 using Azure.Core.Extensions;
 using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Specialized;
+using HealthChecks.Azure.Storage.Blobs;
 using Microsoft.Extensions.Azure;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -55,7 +57,7 @@ protected override void BindSettingsToConfiguration(AzureBlobStorageContainerSet
         }
 
         protected override IHealthCheck CreateHealthCheck(BlobContainerClient client, AzureBlobStorageContainerSettings settings)
-            => new AzureBlobStorageContainerHealthCheck(client);
+            => new AzureBlobStorageHealthCheck(client.GetParentBlobServiceClient(), new AzureBlobStorageHealthCheckOptions { ContainerName = client.Name });
 
         protected override bool GetHealthCheckEnabled(AzureBlobStorageContainerSettings settings)
             => !settings.DisableHealthChecks;
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
index fec1da317e0..bd254d322b3 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
@@ -1503,6 +1503,10 @@ public async Task AddAzureStorageViaPublishMode()
                 };
             });
 
+        var blob = storage.AddBlobs("blob");
+        var queue = storage.AddQueues("queue");
+        var table = storage.AddTables("table");
+
         storage.Resource.Outputs["blobEndpoint"] = "https://myblob";
         storage.Resource.Outputs["queueEndpoint"] = "https://myqueue";
         storage.Resource.Outputs["tableEndpoint"] = "https://mytable";
@@ -1578,7 +1582,6 @@ param principalId string
         Assert.Equal(expectedBicep, storageRolesManifest.BicepText);
 
         // Check blob resource.
-        var blob = storage.AddBlobs("blob");
 
         var connectionStringBlobResource = (IResourceWithConnectionString)blob.Resource;
 
@@ -1593,8 +1596,6 @@ param principalId string
         Assert.Equal(expectedBlobManifest, blobManifest.ToString());
 
         // Check queue resource.
-        var queue = storage.AddQueues("queue");
-
         var connectionStringQueueResource = (IResourceWithConnectionString)queue.Resource;
 
         Assert.Equal("https://myqueue", await connectionStringQueueResource.GetConnectionStringAsync());
@@ -1608,8 +1609,6 @@ param principalId string
         Assert.Equal(expectedQueueManifest, queueManifest.ToString());
 
         // Check table resource.
-        var table = storage.AddTables("table");
-
         var connectionStringTableResource = (IResourceWithConnectionString)table.Resource;
 
         Assert.Equal("https://mytable", await connectionStringTableResource.GetConnectionStringAsync());
diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
index 2252418382b..cc4806a274c 100644
--- a/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
+++ b/tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
@@ -135,9 +135,10 @@ public async Task VerifyAzureStorageEmulatorResource()
 
     [Fact]
     [RequiresDocker]
-    [QuarantinedTest("https://github.com/dotnet/aspire/issues/9139")]
     public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created()
     {
+        var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3));
+
         using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
         var storage = builder.AddAzureStorage("storage").RunAsEmulator();
         var blobs = storage.AddBlobs("BlobConnection");
@@ -146,6 +147,9 @@ public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created()
         using var app = builder.Build();
         await app.StartAsync();
 
+        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
+        await rns.WaitForResourceHealthyAsync(blobContainer.Resource.Name, cancellationToken: cts.Token);
+
         var hb = Host.CreateApplicationBuilder();
         hb.Configuration["ConnectionStrings:BlobConnection"] = await blobs.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None);
         hb.AddAzureBlobClient("BlobConnection");
@@ -153,9 +157,6 @@ public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created()
         using var host = hb.Build();
         await host.StartAsync();
 
-        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
-        await rns.WaitForResourceHealthyAsync(blobContainer.Resource.Name, CancellationToken.None);
-
         var serviceClient = host.Services.GetRequiredService<BlobServiceClient>();
         var blobContainerClient = serviceClient.GetBlobContainerClient("testblobcontainer");
 


Reviews:
user: login: sebastienros
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:13:29Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:15:01Z
user: login: RussKie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:54:14Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T17:15:20Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T17:22:16Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T20:54:57Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T20:55:42Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T21:38:38Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T02:32:27Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T02:36:35Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T19:59:25Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T19:59:55Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T20:37:41Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T20:39:18Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T20:41:51Z
user: login: eerhardt
body: Looks good.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T20:42:23Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T21:41:44Z
user: login: RussKie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:24:10Z
user: login: RussKie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:28:49Z
user: login: RussKie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:31:26Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:53:05Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:54:46Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:55:39Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T22:57:24Z
user: login: eerhardt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T23:07:39Z
user: login: RussKie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T02:30:36Z
user: login: radical
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T18:32:37Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T18:39:27Z
user: login: radical
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T18:41:21Z
user: login: sebastienros
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T18:43:57Z
user: login: radical
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T18:57:10Z

Review comments:
diff_hunk: @@ -0,0 +1,26 @@
+// 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 Aspire.Hosting;
+
+internal sealed class AzureBlobStorageContainerHealthCheck(BlobContainerClient blobContainerClient) : IHealthCheck
+{
+    private readonly BlobContainerClient _blobServiceClient = blobContainerClient ?? throw new ArgumentNullException(nameof(blobContainerClient));
+
+    /// <inheritdoc />
+    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            await _blobServiceClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
path: src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerHealthCheck.cs
user: login: sebastienros
body: > In general, I dislike using health checks to mutate state. They should just be used to check health, not make something "healthy".

Could the creation of the container be done outside, when the client is first created?
created_at: 2025-05-13T05:15:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+// 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 Aspire.Hosting;
+
+internal sealed class AzureBlobStorageContainerHealthCheck(BlobContainerClient blobContainerClient) : IHealthCheck
+{
+    private readonly BlobContainerClient _blobServiceClient = blobContainerClient ?? throw new ArgumentNullException(nameof(blobContainerClient));
+
+    /// <inheritdoc />
+    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            await _blobServiceClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
path: src/Aspire.Hosting.Azure.Storage/AzureBlobStorageContainerHealthCheck.cs
user: login: RussKie
body: Updated
created_at: 2025-05-13T05:54:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -326,10 +294,43 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            var parentResource = resource.Parent;
+            var parentConnectionString = await parentResource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+            if (parentConnectionString is null)
+            {
+                throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{parentResource.Name}' resource but the connection string was null.");
+            }
+
+            blobServiceClient = CreateBlobServiceClient(parentConnectionString);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+        var healthCheckRegistration = new HealthCheckRegistration(
+            healthCheckKey,
+            sp =>
+            {
+                _ = blobServiceClient ?? throw new InvalidOperationException("BlobServiceClient is not initialized.");
+
+                var blobContainerClient = blobServiceClient.GetBlobContainerClient(blobContainerName);
+                blobContainerClient.CreateIfNotExists();
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: This is called every time the health check runs. I don't think that's what we want.
created_at: 2025-05-13T17:15:20Z
author_association: MEMBER
diff_hunk: @@ -326,10 +294,43 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            var parentResource = resource.Parent;
+            var parentConnectionString = await parentResource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+            if (parentConnectionString is null)
+            {
+                throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{parentResource.Name}' resource but the connection string was null.");
+            }
+
+            blobServiceClient = CreateBlobServiceClient(parentConnectionString);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+        var healthCheckRegistration = new HealthCheckRegistration(
+            healthCheckKey,
+            sp =>
+            {
+                _ = blobServiceClient ?? throw new InvalidOperationException("BlobServiceClient is not initialized.");
+
+                var blobContainerClient = blobServiceClient.GetBlobContainerClient(blobContainerName);
+                blobContainerClient.CreateIfNotExists();
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: @RussKie - can you explain why we can't follow the same pattern we are doing for CosmosDB and other databases (Sql Server and Postgres)?
created_at: 2025-05-13T17:22:16Z
author_association: MEMBER
diff_hunk: @@ -130,47 +132,6 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                    Tag = StorageEmulatorContainerImageTags.Tag
                });
 
-        BlobServiceClient? blobServiceClient = null;
-
-        builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
-        {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is 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<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
-        {
-            if (blobServiceClient is null)
-            {
-                throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource.");
-            }
-
-            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.");
-            }
-
-            foreach (var blobContainer in builder.Resource.BlobContainers)
-            {
-                await blobServiceClient.GetBlobContainerClient(blobContainer.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
-            }
-        });
-
-        var healthCheckKey = $"{builder.Resource.Name}_check";
-
-        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp =>
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: Why's this being removed?
created_at: 2025-05-13T20:54:57Z
author_association: MEMBER
diff_hunk: @@ -326,10 +275,41 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            var parentResource = resource.Parent;
+            connectionString = await parentResource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{parentResource.Name}' resource but the connection string was null.");
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobContainerClient? blobContainerClient = null;
+        var healthCheckRegistration = new HealthCheckRegistration(
+            healthCheckKey,
+            sp =>
+            {
+                if (blobContainerClient is null)
+                {
+                    var blobServiceClient = CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string client is not initialized."));
+                    blobContainerClient = blobServiceClient.GetBlobContainerClient(blobContainerName);
+                    blobContainerClient.CreateIfNotExists();
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: I still don't like mutating state during a health check, even if it is the first time. I don't get why we can't follow the same pattern used in CosmosDB and Sql/Postgres. #Closed
created_at: 2025-05-13T20:55:42Z
author_association: MEMBER
diff_hunk: @@ -326,10 +275,41 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            var parentResource = resource.Parent;
+            connectionString = await parentResource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{parentResource.Name}' resource but the connection string was null.");
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobContainerClient? blobContainerClient = null;
+        var healthCheckRegistration = new HealthCheckRegistration(
+            healthCheckKey,
+            sp =>
+            {
+                if (blobContainerClient is null)
+                {
+                    var blobServiceClient = CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string client is not initialized."));
+                    blobContainerClient = blobServiceClient.GetBlobContainerClient(blobContainerName);
+                    blobContainerClient.CreateIfNotExists();
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: sebastienros
body: My understanding is that we can create the dbs when CosmosDB or Postgres or SqlServer is ready. We can connect to these and have a health check for these so on the ready event we know we can create the dbs.

For storage there is nothing to connect to, no connection string, there isn't a connection string for it, the blob is the thing.

Maybe the reason why there is no health check for storage itself either.
created_at: 2025-05-13T21:38:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,7 +57,7 @@ protected override void BindSettingsToConfiguration(AzureBlobStorageContainerSet
         }
 
         protected override IHealthCheck CreateHealthCheck(BlobContainerClient client, AzureBlobStorageContainerSettings settings)
-            => new AzureBlobStorageContainerHealthCheck(client);
+            => new AzureBlobStorageHealthCheck(client.GetParentBlobServiceClient(), new AzureBlobStorageHealthCheckOptions { ContainerName = client.Name });
path: src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs
user: login: eerhardt
body: Do we want to check that the container exists? That's what the existing code did.
created_at: 2025-05-14T02:32:27Z
author_association: MEMBER
diff_hunk: @@ -146,16 +145,16 @@ public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created()
         using var app = builder.Build();
         await app.StartAsync();
 
+        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
+        await rns.WaitForResourceHealthyAsync(storage.Resource.Name, CancellationToken.None);
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: eerhardt
body: Why can't we wait for the `blobContainer` resource? Is it because there is no "health check" for the blobContainer resource?
created_at: 2025-05-14T02:36:35Z
author_association: MEMBER
diff_hunk: @@ -146,16 +145,16 @@ public async Task VerifyAzureStorageEmulator_blobcontainer_auto_created()
         using var app = builder.Build();
         await app.StartAsync();
 
+        var rns = app.Services.GetRequiredService<ResourceNotificationService>();
+        await rns.WaitForResourceHealthyAsync(storage.Resource.Name, CancellationToken.None);
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: sebastienros
body: Chatted offline and this is the reason. The health checks were added back on the child resources because the check is not applied on parent resources like WaitFor is doing.
created_at: 2025-05-14T19:59:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,7 +57,7 @@ protected override void BindSettingsToConfiguration(AzureBlobStorageContainerSet
         }
 
         protected override IHealthCheck CreateHealthCheck(BlobContainerClient client, AzureBlobStorageContainerSettings settings)
-            => new AzureBlobStorageContainerHealthCheck(client);
+            => new AzureBlobStorageHealthCheck(client.GetParentBlobServiceClient(), new AzureBlobStorageHealthCheckOptions { ContainerName = client.Name });
path: src/Components/Aspire.Azure.Storage.Blobs/AspireBlobStorageExtensions.BlobStorageContainerComponent.cs
user: login: sebastienros
body: That's what the `AzureBlobStorageHealthCheckOptions` is here for
created_at: 2025-05-14T19:59:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,34 +132,28 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                });
 
         BlobServiceClient? blobServiceClient = null;
-
         builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
         {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is null)
-            {
-                throw new DistributedApplicationException($"BeforeResourceStartedEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
-            }
+            // The BlobServiceClient is created before the health check is run.
+            // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
+            // we use BeforeResourceStartedEvent
 
+            var connectionString = await builder.Resource.GetEmulatorConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
             blobServiceClient = CreateBlobServiceClient(connectionString);
         });
 
         builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
         {
-            if (blobServiceClient is null)
-            {
-                throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource.");
-            }
+            // This event is triggered when the health check is healthy.
 
-            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 InvalidOperationException("BlobServiceClient is not initialized.");
             }
 
-            foreach (var blobContainer in builder.Resource.BlobContainers)
+            foreach (var container in builder.Resource.BlobContainers)
             {
-                await blobServiceClient.GetBlobContainerClient(blobContainer.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
+                await blobServiceClient.GetBlobContainerClient(container.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).WaitAsync(ct).ConfigureAwait(false);
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: ```suggestion
                await blobServiceClient.GetBlobContainerClient(container.BlobContainerName).CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
```

This shouldn't be needed. We pass the ct into `CreateIfNotExistsAsync`.
created_at: 2025-05-15T20:37:41Z
author_association: MEMBER
diff_hunk: @@ -131,34 +132,28 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                });
 
         BlobServiceClient? blobServiceClient = null;
-
         builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
         {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is null)
-            {
-                throw new DistributedApplicationException($"BeforeResourceStartedEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
-            }
+            // The BlobServiceClient is created before the health check is run.
+            // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
+            // we use BeforeResourceStartedEvent
 
+            var connectionString = await builder.Resource.GetEmulatorConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: ```suggestion
            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
```

I know we are in RunAsEmulator, but it feels better to call `GetBlobConnectionString` vs `GetEmulatorConnectionString`
created_at: 2025-05-15T20:39:18Z
author_association: MEMBER
diff_hunk: @@ -326,10 +324,26 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.Parent.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp =>
+        {
+            return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized."));
+        },
+        optionsFactory: sp => new HealthChecks.Azure.Storage.Blobs.AzureBlobStorageHealthCheckOptions { ContainerName = blobContainerName },
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: eerhardt
body: Does this need to use a factory? Or can it just create the options directly inline?
created_at: 2025-05-15T20:41:50Z
author_association: MEMBER
diff_hunk: @@ -326,10 +324,26 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
         blobContainerName ??= name;
 
         AzureBlobStorageContainerResource resource = new(name, blobContainerName, builder.Resource);
-
         builder.Resource.Parent.BlobContainers.Add(resource);
 
-        return builder.ApplicationBuilder.AddResource(resource);
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.Parent.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp =>
+        {
+            return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized."));
+        },
+        optionsFactory: sp => new HealthChecks.Azure.Storage.Blobs.AzureBlobStorageHealthCheckOptions { ContainerName = blobContainerName },
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: sebastienros
body: Factory only
created_at: 2025-05-15T21:41:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1578,7 +1582,6 @@ param principalId string
         Assert.Equal(expectedBicep, storageRolesManifest.BicepText);
path: tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
user: login: RussKie
body: If we're changing this test, then we should change this check to use Verify
created_at: 2025-05-15T22:24:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,34 +132,28 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                });
 
         BlobServiceClient? blobServiceClient = null;
-
         builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
         {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is null)
-            {
-                throw new DistributedApplicationException($"BeforeResourceStartedEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
-            }
+            // The BlobServiceClient is created before the health check is run.
+            // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
+            // we use BeforeResourceStartedEvent
 
+            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
             blobServiceClient = CreateBlobServiceClient(connectionString);
         });
 
         builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
         {
-            if (blobServiceClient is null)
-            {
-                throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource.");
-            }
+            // This event is triggered when the health check is healthy.
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: RussKie
body: ```suggestion
            // This event is triggered when the emulator has started, and BlobServiceClient is marked as healthy.
```
created_at: 2025-05-15T22:28:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -308,7 +291,22 @@ public static IResourceBuilder<AzureBlobStorageResource> AddBlobs(this IResource
         ArgumentException.ThrowIfNullOrEmpty(name);
 
         var resource = new AzureBlobStorageResource(name, builder.Resource);
-        return builder.ApplicationBuilder.AddResource(resource);
+
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp =>
+        {
+            return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized."));
+        }, name: healthCheckKey);
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: RussKie
body: Why do we need to duplicate the HC here? Or why do we need to keep the HC on lines:160-167?
created_at: 2025-05-15T22:31:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -308,7 +291,22 @@ public static IResourceBuilder<AzureBlobStorageResource> AddBlobs(this IResource
         ArgumentException.ThrowIfNullOrEmpty(name);
 
         var resource = new AzureBlobStorageResource(name, builder.Resource);
-        return builder.ApplicationBuilder.AddResource(resource);
+
+        string? connectionString = null;
+        builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+        });
+
+        var healthCheckKey = $"{resource.Name}_check";
+
+        BlobServiceClient? blobServiceClient = null;
+        builder.ApplicationBuilder.Services.AddHealthChecks().AddAzureBlobStorage(sp =>
+        {
+            return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized."));
+        }, name: healthCheckKey);
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: sebastienros
body: Here it's on the Blobs resource it. Line 160 is on the Emulator resource. Doing it on the storage is not sufficient as the `WaitForHealthyAsync` doesn't bubble up to the parent resources.

If it were just for the existing tests we could probably not have this specific one. But it's more consistent to keep it if we do it for containers.
created_at: 2025-05-15T22:53:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,34 +132,28 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                });
 
         BlobServiceClient? blobServiceClient = null;
-
         builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
         {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is null)
-            {
-                throw new DistributedApplicationException($"BeforeResourceStartedEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
-            }
+            // The BlobServiceClient is created before the health check is run.
+            // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
+            // we use BeforeResourceStartedEvent
 
+            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
             blobServiceClient = CreateBlobServiceClient(connectionString);
         });
 
         builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
         {
-            if (blobServiceClient is null)
-            {
-                throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource.");
-            }
+            // This event is triggered when the health check is healthy.
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: sebastienros
body: I disagree with the change, that's not what I wanted to convey. I am saying that this event happens after the health check, only if it's healthy. Yes it implied the emulator has started (with more information than just "started") and "BlobServiceClient" is healthy doesn't mean much. The storage itself is healthy, the client is not "marked" anything.
created_at: 2025-05-15T22:54:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1578,7 +1582,6 @@ param principalId string
         Assert.Equal(expectedBicep, storageRolesManifest.BicepText);
path: tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
user: login: sebastienros
body: Sure. Here I only moved the calls that are mutating the service collection while it has already been built.
created_at: 2025-05-15T22:55:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1578,7 +1582,6 @@ param principalId string
         Assert.Equal(expectedBicep, storageRolesManifest.BicepText);
path: tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
user: login: sebastienros
body: I'd rather update it if we were to change the content of the bicep, or should we also update the whole class if? What was the decision about how we migrate the rest of the tests?
created_at: 2025-05-15T22:57:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1578,7 +1582,6 @@ param principalId string
         Assert.Equal(expectedBicep, storageRolesManifest.BicepText);
path: tests/Aspire.Hosting.Azure.Tests/AzureBicepResourceTests.cs
user: login: eerhardt
body: > rather update it if we were to change the content of the bicep

When we migrate to Verify that's the only change that should be made in the PR. It would be super hard to diff and track down changes in history if we were to change the behavior and change the verification baseline in the same change.
created_at: 2025-05-15T23:07:39Z
author_association: MEMBER
diff_hunk: @@ -131,34 +132,28 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
                });
 
         BlobServiceClient? blobServiceClient = null;
-
         builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
         {
-            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false);
-            if (connectionString is null)
-            {
-                throw new DistributedApplicationException($"BeforeResourceStartedEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
-            }
+            // The BlobServiceClient is created before the health check is run.
+            // We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
+            // we use BeforeResourceStartedEvent
 
+            var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
             blobServiceClient = CreateBlobServiceClient(connectionString);
         });
 
         builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
         {
-            if (blobServiceClient is null)
-            {
-                throw new DistributedApplicationException($"BlobServiceClient was not created for the '{builder.Resource.Name}' resource.");
-            }
+            // This event is triggered when the health check is healthy.
path: src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs
user: login: RussKie
body: Fair enough, but "health check is healthy" isn't providing much information either. There are multiple health checks now; it would be good to clarify which health check is triggering this event.
created_at: 2025-05-16T02:30:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -135,9 +135,10 @@ public async Task VerifyAzureStorageEmulatorResource()
 
     [Fact]
     [RequiresDocker]
-    [QuarantinedTest("https://github.com/dotnet/aspire/issues/9139")]
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: radical
body: Are we sure that this can be dropped? For quarantined tests we want to take it out after it has been green for a certain number of runs (~100 right now).
created_at: 2025-05-16T18:32:37Z
author_association: MEMBER
diff_hunk: @@ -135,9 +135,10 @@ public async Task VerifyAzureStorageEmulatorResource()
 
     [Fact]
     [RequiresDocker]
-    [QuarantinedTest("https://github.com/dotnet/aspire/issues/9139")]
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: sebastienros
body: Will add it back then.

What else? Reopening the issues? Is tracking automatic or is there a process to follow to unquarantine like for aspnet?
created_at: 2025-05-16T18:39:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -135,9 +135,10 @@ public async Task VerifyAzureStorageEmulatorResource()
 
     [Fact]
     [RequiresDocker]
-    [QuarantinedTest("https://github.com/dotnet/aspire/issues/9139")]
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: radical
body: Yes, re-open the issue. And it will be tracked automatically. And I will take care of taking it out of quarantine for now. It will get semi-automated in medium term.
created_at: 2025-05-16T18:41:21Z
author_association: MEMBER
diff_hunk: @@ -135,9 +135,10 @@ public async Task VerifyAzureStorageEmulatorResource()
 
     [Fact]
     [RequiresDocker]
-    [QuarantinedTest("https://github.com/dotnet/aspire/issues/9139")]
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: sebastienros
body: https://github.com/dotnet/aspire/pull/9364
created_at: 2025-05-16T18:43:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -135,9 +135,10 @@ public async Task VerifyAzureStorageEmulatorResource()
 
     [Fact]
     [RequiresDocker]
-    [QuarantinedTest("https://github.com/dotnet/aspire/issues/9139")]
path: tests/Aspire.Hosting.Azure.Tests/AzureStorageEmulatorFunctionalTests.cs
user: login: radical
body: Thank you!
created_at: 2025-05-16T18:57:10Z
author_association: MEMBER

PR comments:
user: login: davidfowl
created_at: 2025-05-08T09:05:34Z
body: Hmm, we had this debate when @sebastienros did the database creation and I thought we decided to use the ResourceReady event (which runs after health checks).


author_association: CONTRIBUTOR
user: login: eerhardt
created_at: 2025-05-08T14:56:22Z
body: > Hmm, we had this debate when @sebastienros did the database creation and I thought we decided to use the ResourceReady event (which runs after health checks).

For databases, do we:

https://github.com/dotnet/aspire/blob/88ff939ce7906abea6a88a58fa46bb9cf1ee54ac/src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs#L58-L77

https://github.com/dotnet/aspire/blob/88ff939ce7906abea6a88a58fa46bb9cf1ee54ac/src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs#L66-L90

In general, I dislike using health checks to mutate state. They should just be used to check health, not make something "healthy".
author_association: MEMBER
user: login: RussKie
created_at: 2025-05-08T23:09:09Z
body: I am not super fond of this result, but I couldn't find a way to add a healthcheck for individual blob containers. 
I originally added blob container creation within ResourceReadyEvent, however, it appears this makes the test flaky - there's a race condition, and the client may attempt to access blob container before those get created.
If I add a check alongside the blob storage, then ResourceReadyEvent never get fired (since no containers yet exist). I couldn't add a healthcheck within the even - the service collection at this point is already locked.

Any suggestions?

author_association: CONTRIBUTOR
user: login: eerhardt
created_at: 2025-05-09T16:16:20Z
body: > but I couldn't find a way to add a healthcheck for individual blob containers.

I don't think we do healthchecks for child resources anywhere else. For example, CosmosDB seems to honly have it for the whole service.

https://github.com/dotnet/aspire/blob/e3d170c14198caf53e62818e1f71a0526449c585/src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs#L104-L122

Because the `ResourceReadyEvent` blocks the resource's "healthy" state until all ResourceReadyEvent listeners complete, the parent resource won't be marked "healthy" until creating the child resources is complete. And the child resources won't be "healthy" until the parent resource is "healthy".

I think we should be able to follow the existing patterns in Sql, Postgres, and in Azure CosmosDB. What doesn't work about the existing pattern?

Any other suggestions here @sebastienros or @mitchdenny ?
author_association: MEMBER
user: login: sebastienros
created_at: 2025-05-09T16:26:06Z
body: > I don't think we do healthchecks for child resources anywhere else

SqlServer/Postgres databases have one. It's done by using their own connection string which has the `Database=` property in it so establishing the connection retrieved from `ConnectionStringAvailableEvent` is sufficient.
author_association: CONTRIBUTOR
user: login: RussKie
created_at: 2025-05-13T01:03:17Z
body: Thanks @sebastienros for help and guidance.  How does this look now?


author_association: CONTRIBUTOR