Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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==";
Comment thread
afscrome marked this conversation as resolved.

internal override void ApplyUIEnvironment(EnvironmentCallbackContext context)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents the Blob, Queue and Table Storage APIs exposed by a Floci Azure emulator resource.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="name">The name of the resource.</param>
/// <param name="parent">The parent Floci Azure emulator resource.</param>
[AspireExport(ExposeProperties = true)]
public class FlociAzureStorageResource(
string name,
FlociAzureContainerResource parent) : Resource(name),
IResourceWithParent<FlociAzureContainerResource>,
IResourceWithConnectionString
{
internal const string DefaultName = "storage";

/// <summary>
/// Gets the parent Floci Azure emulator resource.
/// </summary>
public FlociAzureContainerResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent));

/// <summary>
/// Gets the storage account name.
/// </summary>
public string AccountName => FlociAzureContainerResource.DefaultAccountName;

/// <summary>
/// Gets the service endpoint shared by the Blob, Queue and Table APIs.
/// </summary>
/// <remarks>
/// The host is the endpoint's IPv4 address rather than <c>localhost</c>: the Azure Storage SDKs
/// only read the account name from the URL path when the host is an IP address, so a
/// <c>localhost</c> URL makes them treat <c>localhost</c> as the account name.
/// </remarks>
public ReferenceExpression ServiceEndpoint =>
ReferenceExpression.Create(
$"{Parent.Scheme}://{Parent.PrimaryEndpoint.Property(EndpointProperty.IPV4Host)}:{Parent.Port}/{AccountName}");

/// <summary>
/// Gets the storage connection string expression, carrying the <c>BlobEndpoint</c>,
/// <c>QueueEndpoint</c> and <c>TableEndpoint</c> entries so every Azure Storage SDK client
/// resolves to the emulator.
/// </summary>
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create(
$"DefaultEndpointsProtocol={Parent.Scheme};AccountName={AccountName};AccountKey={FlociAzureContainerResource.DefaultAccountKey};BlobEndpoint={ServiceEndpoint};QueueEndpoint={ServiceEndpoint};TableEndpoint={ServiceEndpoint};");

IEnumerable<KeyValuePair<string, ReferenceExpression>> IResourceWithConnectionString.GetConnectionProperties() =>
Parent.CombineProperties([
new("AccountName", ReferenceExpression.Create($"{AccountName}")),
new("BlobEndpoint", ServiceEndpoint),
new("QueueEndpoint", ServiceEndpoint),
new("TableEndpoint", ServiceEndpoint)
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,33 @@ public static IResourceBuilder<FlociAzureCosmosResource> WithCosmos(
.WithParentRelationship(builder);
}

/// <summary>
/// Adds a child resource representing the Blob, Queue and Table Storage APIs exposed by the
/// Floci Azure emulator.
/// </summary>
/// <remarks>
/// Reference the returned resource with Aspire's standard <c>WithReference</c> API to inject its
/// storage connection string (e.g. for <c>AddAzureBlobServiceClient</c>). The resource has no
/// health check of its own; it reports the parent emulator's health, so dependents can
/// <c>WaitFor</c> it directly.
/// </remarks>
/// <ats-summary>Adds a Storage child resource to the Floci Azure emulator</ats-summary>
/// <param name="builder">The Floci Azure resource builder.</param>
/// <param name="name">The name of the Storage resource (default: <c>storage</c>).</param>
/// <returns>A reference to the <see cref="IResourceBuilder{FlociAzureStorageResource}"/> for further configuration.</returns>
[AspireExport]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d8dfaf8. The TypeScript AppHost now calls withStorage({ name: 'storage' }) on the runtime path, reads accountName(), serviceEndpoint() and connectionStringExpression(), and passes the handle to withReference(storage) and waitFor(storage) on floci-api. TypeScriptAppHostCompilesAndStarts passes locally.

public static IResourceBuilder<FlociAzureStorageResource> WithStorage(
this IResourceBuilder<FlociAzureContainerResource> builder,
[ResourceName] string name = FlociAzureStorageResource.DefaultName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);

return builder.ApplicationBuilder
.AddResource(new FlociAzureStorageResource(name, builder.Resource))
.WithParentRelationship(builder);
}

/// <summary>
/// Mounts the Docker socket into the Floci Azure container so that Azure Functions and other
/// container-backed services can launch sibling containers.
Expand Down
34 changes: 34 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.Floci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MyApi>("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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

<ItemGroup>
<PackageReference Include="Azure.Messaging.ServiceBus" />
<PackageReference Include="Azure.Storage.Blobs" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,89 @@ public void WithCosmosResourceNameShouldNotBeEmpty()
Assert.Throws<ArgumentException>(() => 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<ResourceRelationshipAnnotation>(),
annotation => annotation.Type == "Parent" && ReferenceEquals(annotation.Resource, floci.Resource));

var connectionStringReference = Assert.IsType<ConnectionStringReference>(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<EndpointReferenceExpression>(),
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<HealthCheckAnnotation>());
Assert.True(storage.Resource.TryGetAnnotationsIncludingAncestorsOfType<HealthCheckAnnotation>(out var healthChecks));
Assert.Equal(
floci.Resource.Annotations.OfType<HealthCheckAnnotation>().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<ConnectionStringReference>(envVars["ConnectionStrings__blobs"]);
Assert.Same(storage.Resource, connectionStringReference.Resource);
}

[Fact]
public void WithStorageBuilderShouldNotBeNull()
{
IResourceBuilder<FlociAzureContainerResource> builder = null!;

Assert.Throws<ArgumentNullException>(() => builder.WithStorage());
}

[Fact]
public void WithStorageResourceNameShouldNotBeEmpty()
{
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
var floci = builder.AddFlociAzure("floci-az");

Assert.Throws<ArgumentException>(() => floci.WithStorage(GetInvalidResourceName()));
}

private static string GetInvalidResourceName() => string.Empty;

private static async Task<Dictionary<string, object>> ResolveEnvironmentAsync<T>(
Expand Down
Loading