diff --git a/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts b/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
index 0a9f7ccce..e627ad600 100644
--- a/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
+++ b/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
@@ -17,6 +17,10 @@ const serviceBus = await flociAzure.withServiceBus({ name: 'servicebus' });
await serviceBus.amqpEndpoint();
await serviceBus.amqpTlsEndpoint();
await serviceBus.connectionStringExpression();
+const storage = await flociAzure.withStorage({ name: 'storage' });
+await storage.accountName();
+await storage.serviceEndpoint();
+await storage.connectionStringExpression();
// A single Floci UI console browses all three clouds — flociAws.withFlociUI() creates the
// console wired to AWS, then withCloudReference* attaches the Azure and GCP resources to it.
@@ -38,9 +42,11 @@ const apiService = await builder.addProject("floci-api", apiServiceProjectPath)
.withFlociAzureReference(flociAzure)
.withFlociGcpReference(flociGcp)
.withFlociAzureServiceBusReference(serviceBus)
+ .withReference(storage)
.waitFor(flociAws)
.waitFor(flociAzure)
- .waitFor(flociGcp);
+ .waitFor(flociGcp)
+ .waitFor(storage);
// Resolve the returned child handle from a real container and require an AMQP response.
await builder.addContainer('servicebus-probe', 'node:22-alpine')
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureContainerResource.cs
index 9286669a2..bbbaad507 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureContainerResource.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureContainerResource.cs
@@ -10,9 +10,10 @@ public class FlociAzureContainerResource(string name) : FlociContainerResource(n
internal const int EndpointPort = 4577;
internal const string HostnameEnvVar = "FLOCI_AZ_HOSTNAME";
- // Well-known Azurite-compatible dev credentials that floci-az accepts by default (no auth enforced).
+ // Well-known Azurite-compatible dev credentials. floci-az validates SharedKey signatures against this
+ // exact key, so it must match the emulator's default byte for byte.
internal const string DefaultAccountName = "devstoreaccount1";
- internal const string DefaultAccountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMh0==";
+ internal const string DefaultAccountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
internal override void ApplyUIEnvironment(EnvironmentCallbackContext context)
{
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureStorageResource.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureStorageResource.cs
new file mode 100644
index 000000000..377360fdc
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureStorageResource.cs
@@ -0,0 +1,59 @@
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents the Blob, Queue and Table Storage APIs exposed by a Floci Azure emulator resource.
+///
+///
+/// The resource has no health check of its own: Aspire applies the parent emulator's health check
+/// to it, so it becomes healthy as soon as the Floci Azure container is.
+///
+/// The name of the resource.
+/// The parent Floci Azure emulator resource.
+[AspireExport(ExposeProperties = true)]
+public class FlociAzureStorageResource(
+ string name,
+ FlociAzureContainerResource parent) : Resource(name),
+ IResourceWithParent,
+ IResourceWithConnectionString
+{
+ internal const string DefaultName = "storage";
+
+ ///
+ /// Gets the parent Floci Azure emulator resource.
+ ///
+ public FlociAzureContainerResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent));
+
+ ///
+ /// Gets the storage account name.
+ ///
+ public string AccountName => FlociAzureContainerResource.DefaultAccountName;
+
+ ///
+ /// Gets the service endpoint shared by the Blob, Queue and Table APIs.
+ ///
+ ///
+ /// The host is the endpoint's IPv4 address rather than localhost: the Azure Storage SDKs
+ /// only read the account name from the URL path when the host is an IP address, so a
+ /// localhost URL makes them treat localhost as the account name.
+ ///
+ public ReferenceExpression ServiceEndpoint =>
+ ReferenceExpression.Create(
+ $"{Parent.Scheme}://{Parent.PrimaryEndpoint.Property(EndpointProperty.IPV4Host)}:{Parent.Port}/{AccountName}");
+
+ ///
+ /// Gets the storage connection string expression, carrying the BlobEndpoint,
+ /// QueueEndpoint and TableEndpoint entries so every Azure Storage SDK client
+ /// resolves to the emulator.
+ ///
+ public ReferenceExpression ConnectionStringExpression =>
+ ReferenceExpression.Create(
+ $"DefaultEndpointsProtocol={Parent.Scheme};AccountName={AccountName};AccountKey={FlociAzureContainerResource.DefaultAccountKey};BlobEndpoint={ServiceEndpoint};QueueEndpoint={ServiceEndpoint};TableEndpoint={ServiceEndpoint};");
+
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties() =>
+ Parent.CombineProperties([
+ new("AccountName", ReferenceExpression.Create($"{AccountName}")),
+ new("BlobEndpoint", ServiceEndpoint),
+ new("QueueEndpoint", ServiceEndpoint),
+ new("TableEndpoint", ServiceEndpoint)
+ ]);
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs
index ab11328d3..9bd0d5950 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs
@@ -254,6 +254,33 @@ public static IResourceBuilder WithCosmos(
.WithParentRelationship(builder);
}
+ ///
+ /// Adds a child resource representing the Blob, Queue and Table Storage APIs exposed by the
+ /// Floci Azure emulator.
+ ///
+ ///
+ /// Reference the returned resource with Aspire's standard WithReference API to inject its
+ /// storage connection string (e.g. for AddAzureBlobServiceClient). The resource has no
+ /// health check of its own; it reports the parent emulator's health, so dependents can
+ /// WaitFor it directly.
+ ///
+ /// Adds a Storage child resource to the Floci Azure emulator
+ /// The Floci Azure resource builder.
+ /// The name of the Storage resource (default: storage).
+ /// A reference to the for further configuration.
+ [AspireExport]
+ public static IResourceBuilder WithStorage(
+ this IResourceBuilder builder,
+ [ResourceName] string name = FlociAzureStorageResource.DefaultName)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ return builder.ApplicationBuilder
+ .AddResource(new FlociAzureStorageResource(name, builder.Resource))
+ .WithParentRelationship(builder);
+ }
+
///
/// Mounts the Docker socket into the Floci Azure container so that Azure Functions and other
/// container-backed services can launch sibling containers.
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
index 96d9a2a8b..1f68895be 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
@@ -107,6 +107,40 @@ builder.AddAzureCosmosClient("cosmos");
The Cosmos child resource is additive, so combine `WithReference(cosmos)` with `WithReference(azure)` when you also want the base endpoint / storage variables. (Talking to the floci Cosmos emulator over HTTP from the .NET SDK still needs the usual client-side settings — Gateway mode, and HTTP/1.1 — which are the app's concern, as with any local Cosmos emulator.)
+For **Storage** (Blob, Queue and Table), use `WithStorage()` / `withStorage()` to model the storage APIs as a child resource, then reference it through Aspire's standard connection-string flow:
+
+```csharp
+var azure = builder.AddFlociAzure("floci-az");
+var storage = azure.WithStorage();
+
+builder.AddProject("api")
+ .WithReference(storage) // ConnectionStrings__storage
+ .WaitFor(storage);
+```
+
+```typescript
+const azure = await builder.addFlociAzure('floci-az');
+const storage = await azure.withStorage();
+
+await builder.addProject('api', '../MyApi/MyApi.csproj')
+ .withReference(storage)
+ .waitFor(storage);
+```
+
+App side, this is the standard Aspire flow:
+
+```csharp
+builder.AddAzureBlobServiceClient("storage");
+```
+
+| Variable | Value |
+|---|---|
+| `ConnectionStrings__{resourceName}` (default `storage`) | `DefaultEndpointsProtocol={scheme};AccountName=devstoreaccount1;AccountKey=…;BlobEndpoint={scheme}://{ip}:{port}/devstoreaccount1;QueueEndpoint=…;TableEndpoint=…;` |
+
+The endpoints use the emulator's IPv4 address (`127.0.0.1` for host processes) instead of `localhost`, because the Azure Storage SDKs only read the account name from the URL path when the host is an IP address. The storage resource has no health check of its own: it reports the Floci Azure container's health, so `WaitFor(storage)` waits until the emulator is ready.
+
+In an AppHost that also publishes, add Aspire's `AddAzureStorage` resource in the publish branch and reference the Floci storage child in the run branch. Pointing an `AzureStorageResource` at Floci with a connection-string redirect instead leaves Aspire's Azure Storage health checks waiting for a connection string that is never provisioned, so those resources stay `Unhealthy`.
+
For **Service Bus**, use `WithServiceBus()` / `withServiceBus()` to model the AMQP data plane as a child resource, then reference it with `WithReference()` / `withFlociAzureServiceBusReference()`:
```csharp
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureStorageFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureStorageFunctionalTests.cs
new file mode 100644
index 000000000..f32907f2d
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureStorageFunctionalTests.cs
@@ -0,0 +1,50 @@
+using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+using Azure.Storage.Blobs;
+using Azure.Storage.Sas;
+using CommunityToolkit.Aspire.Testing;
+
+namespace CommunityToolkit.Aspire.Hosting.Floci.Tests;
+
+[RequiresDocker]
+public class AzureStorageFunctionalTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact]
+ public async Task StorageBecomesHealthyAndSupportsBlobSdkRoundTrip()
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromMinutes(3));
+ var cancellationToken = timeout.Token;
+
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+ var azure = builder.AddFlociAzure("floci-az");
+ var storage = azure.WithStorage();
+
+ await using var app = await builder.BuildAsync(cancellationToken);
+ await app.StartAsync(cancellationToken);
+ await app.ResourceNotifications.WaitForResourceHealthyAsync(storage.Resource.Name, cancellationToken);
+
+ string? connectionString = await app.GetConnectionStringAsync(storage.Resource.Name, cancellationToken);
+ Assert.NotNull(connectionString);
+
+ var service = new BlobServiceClient(connectionString);
+ Assert.Equal(storage.Resource.AccountName, service.AccountName);
+
+ var container = service.GetBlobContainerClient($"test-{Guid.NewGuid():N}");
+ await container.CreateAsync(cancellationToken: cancellationToken);
+ var blob = container.GetBlobClient("greeting.txt");
+ await blob.UploadAsync(BinaryData.FromString("hello"), cancellationToken);
+
+ var download = await blob.DownloadContentAsync(cancellationToken);
+ Assert.Equal("hello", download.Value.Content.ToString());
+
+ // SAS generation needs the account name from the URL path, which only works for IP hosts.
+ Assert.True(blob.CanGenerateSasUri);
+ var sasUri = blob.GenerateSasUri(BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddMinutes(5));
+ Assert.StartsWith($"{service.Uri.Scheme}://127.0.0.1:", sasUri.ToString());
+ var sasDownload = await new BlobClient(sasUri).DownloadContentAsync(cancellationToken);
+ Assert.Equal("hello", sasDownload.Value.Content.ToString());
+
+ await container.DeleteAsync(cancellationToken: cancellationToken);
+ }
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/CommunityToolkit.Aspire.Hosting.Floci.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/CommunityToolkit.Aspire.Hosting.Floci.Tests.csproj
index 1c45cfcb1..6118acd18 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/CommunityToolkit.Aspire.Hosting.Floci.Tests.csproj
+++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/CommunityToolkit.Aspire.Hosting.Floci.Tests.csproj
@@ -14,6 +14,7 @@
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs
index d3fcb6cd4..8e1cbc31d 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs
@@ -186,6 +186,89 @@ public void WithCosmosResourceNameShouldNotBeEmpty()
Assert.Throws(() => floci.WithCosmos(GetInvalidResourceName()));
}
+ [Fact]
+ public async Task WithStorageCreatesChildResourceUsedByStandardWithReference()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var floci = builder.AddFlociAzure("floci-az");
+ var storage = floci.WithStorage();
+ var worker = builder.AddExecutable("worker", "dotnet", ".").WithReference(storage);
+
+ var envVars = await ResolveEnvironmentAsync(builder, worker);
+
+ Assert.Equal("storage", storage.Resource.Name);
+ Assert.Same(floci.Resource, storage.Resource.Parent);
+ Assert.Contains(
+ storage.Resource.Annotations.OfType(),
+ annotation => annotation.Type == "Parent" && ReferenceEquals(annotation.Resource, floci.Resource));
+
+ var connectionStringReference = Assert.IsType(envVars["ConnectionStrings__storage"]);
+ Assert.Same(storage.Resource, connectionStringReference.Resource);
+ var connectionString = storage.Resource.ConnectionStringExpression.ValueExpression;
+ Assert.Contains("DefaultEndpointsProtocol={floci-az.bindings.azure.scheme};", connectionString);
+ Assert.Contains($"AccountName={FlociAzureContainerResource.DefaultAccountName};", connectionString);
+ Assert.Contains($"AccountKey={FlociAzureContainerResource.DefaultAccountKey};", connectionString);
+
+ // The Storage SDKs only read the account name from the path when the host is an IP address.
+ var serviceEndpoint =
+ $"{{floci-az.bindings.azure.scheme}}://{{floci-az.bindings.azure.host}}:{{floci-az.bindings.azure.port}}/{FlociAzureContainerResource.DefaultAccountName}";
+ Assert.Equal(serviceEndpoint, storage.Resource.ServiceEndpoint.ValueExpression);
+ Assert.Contains(
+ storage.Resource.ServiceEndpoint.ValueProviders.OfType(),
+ expression => expression.Property == EndpointProperty.IPV4Host);
+ Assert.Contains($"BlobEndpoint={serviceEndpoint};", connectionString);
+ Assert.Contains($"QueueEndpoint={serviceEndpoint};", connectionString);
+ Assert.Contains($"TableEndpoint={serviceEndpoint};", connectionString);
+ }
+
+ [Fact]
+ public void WithStorageReportsTheParentEmulatorHealth()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var floci = builder.AddFlociAzure("floci-az");
+ var storage = floci.WithStorage();
+
+ Assert.Empty(storage.Resource.Annotations.OfType());
+ Assert.True(storage.Resource.TryGetAnnotationsIncludingAncestorsOfType(out var healthChecks));
+ Assert.Equal(
+ floci.Resource.Annotations.OfType().Select(annotation => annotation.Key),
+ healthChecks.Select(annotation => annotation.Key));
+ }
+
+ [Fact]
+ public async Task WithStorageHonorsCustomResourceName()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var floci = builder.AddFlociAzure("floci-az");
+ var storage = floci.WithStorage(name: "blobs");
+ var worker = builder.AddExecutable("worker", "dotnet", ".").WithReference(storage);
+
+ var envVars = await ResolveEnvironmentAsync(builder, worker);
+
+ var connectionStringReference = Assert.IsType(envVars["ConnectionStrings__blobs"]);
+ Assert.Same(storage.Resource, connectionStringReference.Resource);
+ }
+
+ [Fact]
+ public void WithStorageBuilderShouldNotBeNull()
+ {
+ IResourceBuilder builder = null!;
+
+ Assert.Throws(() => builder.WithStorage());
+ }
+
+ [Fact]
+ public void WithStorageResourceNameShouldNotBeEmpty()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+ var floci = builder.AddFlociAzure("floci-az");
+
+ Assert.Throws(() => floci.WithStorage(GetInvalidResourceName()));
+ }
+
private static string GetInvalidResourceName() => string.Empty;
private static async Task> ResolveEnvironmentAsync(