From ec5690feee0facdbb62fb3a5159998d811ab85c5 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Mon, 31 Aug 2026 15:54:33 +0100
Subject: [PATCH] feat(floci): model Service Bus as a child resource of
AddFlociAzure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WithServiceBus() adds a FlociAzureServiceBusResource child that enables the
floci-az Service Bus data plane (MOCKED=false, START_ON_BOOT=true) and pins
the host ports its Artemis sidecar publishes — free ports by default so
concurrent AppHosts don't collide. Referencing the child with Aspire's
standard WithReference injects the official emulator's connection-string
shape (Endpoint=sb://localhost:{port};...;UseDevelopmentEmulator=true;) so
AddAzureServiceBusClient works unchanged.
The sidecar publishes directly on the Docker host, outside Aspire's endpoint
model, so the endpoint is host-relative; the management plane stays on the
base endpoint from WithReference(azure).
Blocked on a floci-az release with start-on-boot support
(floci-io/floci-az#249, PR floci-io/floci-az#250).
Fixes #1552
Claude-Session: https://claude.ai/code/session_01K8waTF6dPtQNUmXz3i8xnG
---
.../FlociAzureServiceBusResource.cs | 73 +++++++++
.../FlociHostingExtension.Azure.cs | 86 +++++++++++
.../README.md | 33 ++++
.../CommunityToolkit.Aspire.Hosting.Floci.cs | 21 +++
.../AzureServiceBusResourceTests.cs | 145 ++++++++++++++++++
5 files changed, 358 insertions(+)
create mode 100644 src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs
create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs
new file mode 100644
index 000000000..beb94dbba
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs
@@ -0,0 +1,73 @@
+using System.Globalization;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents the Service Bus AMQP data plane exposed by a Floci Azure emulator resource.
+///
+///
+/// floci-az serves Service Bus AMQP from an Artemis sidecar container that publishes the
+/// configured ports directly on the Docker host — outside Aspire's endpoint model — so the
+/// endpoint is host-relative (localhost). Sibling containers that need to consume
+/// Service Bus must reach the sidecar over the Docker network instead.
+///
+/// The name of the resource.
+/// Host port the Artemis sidecar publishes for plain AMQP.
+/// Host port the Artemis sidecar publishes for AMQPS (TLS).
+/// The parent Floci Azure emulator resource.
+[AspireExport(ExposeProperties = true)]
+public class FlociAzureServiceBusResource(
+ string name,
+ int amqpPort,
+ int amqpTlsPort,
+ FlociAzureContainerResource parent) : Resource(name),
+ IResourceWithParent,
+ IResourceWithConnectionString
+{
+ internal const string DefaultName = "servicebus";
+
+ // Placeholder from the official Service Bus emulator's connection-string shape; floci-az
+ // does not enforce authentication, the SDK only requires the component to be present.
+ internal const string DefaultSasKey = "SAS_KEY_VALUE";
+
+ ///
+ /// Gets the parent Floci Azure emulator resource.
+ ///
+ public FlociAzureContainerResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent));
+
+ ///
+ /// Gets the host port the Artemis sidecar publishes for plain AMQP.
+ ///
+ public int AmqpPort { get; } = amqpPort;
+
+ ///
+ /// Gets the host port the Artemis sidecar publishes for AMQPS (TLS).
+ ///
+ public int AmqpTlsPort { get; } = amqpTlsPort;
+
+ ///
+ /// Gets the Service Bus AMQP endpoint.
+ ///
+ public ReferenceExpression Endpoint
+ {
+ get
+ {
+ string port = AmqpPort.ToString(CultureInfo.InvariantCulture);
+ return ReferenceExpression.Create($"sb://localhost:{port}");
+ }
+ }
+
+ ///
+ /// Gets the Service Bus connection string expression.
+ /// UseDevelopmentEmulator=true makes the Azure SDKs use plain AMQP (no TLS), matching
+ /// the official Service Bus emulator's connection-string shape.
+ ///
+ public ReferenceExpression ConnectionStringExpression =>
+ ReferenceExpression.Create(
+ $"Endpoint={Endpoint};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey={DefaultSasKey};UseDevelopmentEmulator=true;");
+
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties() =>
+ Parent.CombineProperties([
+ new("Endpoint", Endpoint)
+ ]);
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs
index bd6100db2..070642dcb 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs
@@ -1,3 +1,6 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
using Aspire.Hosting.ApplicationModel;
using CommunityToolkit.Aspire.Hosting.Floci;
@@ -73,6 +76,89 @@ public static IResourceBuilder WithReference(
});
+ ///
+ /// Adds a child resource representing the Service Bus AMQP data plane exposed by the Floci
+ /// Azure emulator, enabling the data plane on the emulator (MOCKED=false,
+ /// START_ON_BOOT=true) and pinning the host ports its Artemis sidecar publishes.
+ ///
+ ///
+ /// Reference the returned resource with Aspire's standard WithReference API to inject
+ /// its Service Bus connection string (e.g. for AddAzureServiceBusClient). The Artemis
+ /// sidecar is a separate container floci-az starts via Docker, so the emulator also needs
+ /// .
+ /// When no ports are passed, free host ports are allocated so concurrent AppHosts don't
+ /// collide. Requires a floci-az release with start-on-boot support (floci-io/floci-az#249).
+ ///
+ /// Adds a Service Bus child resource to the Floci Azure emulator
+ /// The Floci Azure resource builder.
+ /// The name of the Service Bus resource (default: servicebus).
+ /// Host port for plain AMQP (default: a free port).
+ /// Host port for AMQPS/TLS (default: a free port).
+ /// A reference to the for further configuration.
+ [AspireExport]
+ public static IResourceBuilder WithServiceBus(
+ this IResourceBuilder builder,
+ [ResourceName] string name = FlociAzureServiceBusResource.DefaultName,
+ int? amqpPort = null,
+ int? amqpTlsPort = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ // One Service Bus child per emulator — the ports configure the parent container's
+ // environment, so a second child could not have different ones.
+ FlociAzureServiceBusResource? existing = builder.ApplicationBuilder.Resources
+ .OfType()
+ .FirstOrDefault(resource => resource.Parent == builder.Resource);
+ if (existing is not null)
+ {
+ if ((amqpPort is not null && amqpPort != existing.AmqpPort)
+ || (amqpTlsPort is not null && amqpTlsPort != existing.AmqpTlsPort))
+ {
+ throw new InvalidOperationException(
+ $"Service Bus is already configured on '{builder.Resource.Name}' with AMQP port {existing.AmqpPort} (TLS {existing.AmqpTlsPort}) and cannot be reconfigured with different ports.");
+ }
+ return builder.ApplicationBuilder.CreateResourceBuilder(existing);
+ }
+
+ (int fallbackAmqpPort, int fallbackAmqpTlsPort) = GetFreeTcpPorts();
+ FlociAzureServiceBusResource serviceBus = new(
+ name, amqpPort ?? fallbackAmqpPort, amqpTlsPort ?? fallbackAmqpTlsPort, builder.Resource);
+
+ builder
+ .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED", "false")
+ .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT", "true")
+ .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_PORT",
+ serviceBus.AmqpPort.ToString(CultureInfo.InvariantCulture))
+ .WithEnvironment("FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_TLS_PORT",
+ serviceBus.AmqpTlsPort.ToString(CultureInfo.InvariantCulture));
+
+ return builder.ApplicationBuilder
+ .AddResource(serviceBus)
+ .WithParentRelationship(builder);
+ }
+
+ ///
+ /// Allocates two distinct free TCP ports, holding both listeners open until each port is
+ /// read so the second allocation cannot return the first port.
+ ///
+ private static (int First, int Second) GetFreeTcpPorts()
+ {
+ TcpListener first = new(IPAddress.Loopback, 0);
+ TcpListener second = new(IPAddress.Loopback, 0);
+ try
+ {
+ first.Start();
+ second.Start();
+ return (((IPEndPoint)first.LocalEndpoint).Port, ((IPEndPoint)second.LocalEndpoint).Port);
+ }
+ finally
+ {
+ first.Stop();
+ second.Stop();
+ }
+ }
+
///
/// 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 7cbd9b811..a7cc61dac 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
@@ -73,6 +73,39 @@ await builder.addProject('api', '../MyApi/MyApi.csproj')
| `ConnectionStrings__floci-az` | `http://localhost:{port}` (standard Aspire connection string) |
| `AZURE_STORAGE_CONNECTION_STRING` | Development storage connection string pointed at the Floci Azure endpoint, carrying `BlobEndpoint`, `QueueEndpoint` and `TableEndpoint` and the well-known `devstoreaccount1` dev credentials |
+For **Service Bus**, use `WithServiceBus()` / `withServiceBus()` to model the AMQP data plane as a child resource, then reference it through Aspire's standard connection-string flow:
+
+```csharp
+var azure = builder.AddFlociAzure("floci-az")
+ .WithDockerSocket(); // the Artemis sidecar is a sibling container
+var serviceBus = azure.WithServiceBus(); // enables the data plane, pins free host ports
+
+builder.AddProject("api")
+ .WithReference(serviceBus) // ConnectionStrings__servicebus
+ .WaitFor(azure);
+```
+
+```typescript
+const azure = (await builder.addFlociAzure('floci-az')).withDockerSocket();
+const serviceBus = await azure.withServiceBus();
+
+await builder.addProject('api', '../MyApi/MyApi.csproj')
+ .withReference(serviceBus)
+ .waitFor(azure);
+```
+
+App side, this is the standard Aspire flow:
+
+```csharp
+builder.AddAzureServiceBusClient("servicebus");
+```
+
+| Variable | Value |
+|---|---|
+| `ConnectionStrings__{resourceName}` (default `servicebus`) | `Endpoint=sb://localhost:{amqpPort};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` — the official Service Bus emulator's connection-string shape |
+
+`WithServiceBus` sets `FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED=false`, `FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT=true`, and pins the sidecar's published host ports (`FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_PORT`/`_AMQP_TLS_PORT`) — free ports by default so concurrent AppHosts don't collide, or pass `amqpPort`/`amqpTlsPort` explicitly. The sidecar publishes on the Docker host outside Aspire's endpoint model, so the connection string is host-relative (`localhost`); the emulator's management plane (e.g. `ServiceBusAdministrationClient`) stays on the base endpoint from `WithReference(azure)`. Requires `WithDockerSocket()` and a floci-az release with `start-on-boot` support.
+
**GCP**
```csharp
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/api/CommunityToolkit.Aspire.Hosting.Floci.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/api/CommunityToolkit.Aspire.Hosting.Floci.cs
index a3404c264..5132c5a2f 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/api/CommunityToolkit.Aspire.Hosting.Floci.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/api/CommunityToolkit.Aspire.Hosting.Floci.cs
@@ -81,6 +81,9 @@ public static ApplicationModel.IResourceBuilder WithReference WithReference(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder floci)
where TDestination : ApplicationModel.IResourceWithEnvironment { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithServiceBus(this ApplicationModel.IResourceBuilder builder, string name = "servicebus", int? amqpPort = null, int? amqpTlsPort = null) { throw null; }
}
}
@@ -98,6 +101,24 @@ public partial class FlociAzureContainerResource : FlociContainerResource
public FlociAzureContainerResource(string name) : base(default!, default!) { }
}
+ [AspireExport(ExposeProperties = true)]
+ public partial class FlociAzureServiceBusResource : Resource, IResourceWithParent, IResourceWithParent, IResource, IResourceWithConnectionString, IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences
+ {
+ public FlociAzureServiceBusResource(string name, int amqpPort, int amqpTlsPort, FlociAzureContainerResource parent) : base(default!) { }
+
+ public int AmqpPort { get { throw null; } }
+
+ public int AmqpTlsPort { get { throw null; } }
+
+ public ReferenceExpression ConnectionStringExpression { get { throw null; } }
+
+ public ReferenceExpression Endpoint { get { throw null; } }
+
+ public FlociAzureContainerResource Parent { get { throw null; } }
+
+ System.Collections.Generic.IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { throw null; }
+ }
+
public abstract partial class FlociContainerResource : ContainerResource, IResourceWithConnectionString, IResource, IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences
{
protected FlociContainerResource(string name, string endpointName) : base(default!, default) { }
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs
new file mode 100644
index 000000000..7cd6e9ecc
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs
@@ -0,0 +1,145 @@
+using Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.Floci.Tests;
+
+public class AzureServiceBusResourceTests
+{
+ [Fact]
+ public void WithServiceBusCreatesChildResourceWithDefaults()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var azure = builder.AddFlociAzure("floci-az");
+ azure.WithServiceBus();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var serviceBus = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(serviceBus);
+ Assert.Equal("servicebus", serviceBus.Name);
+ Assert.Same(azure.Resource, serviceBus.Parent);
+ Assert.InRange(serviceBus.AmqpPort, 1, 65535);
+ Assert.InRange(serviceBus.AmqpTlsPort, 1, 65535);
+ Assert.NotEqual(serviceBus.AmqpPort, serviceBus.AmqpTlsPort);
+ }
+
+ [Fact]
+ public void WithServiceBusHonorsExplicitPorts()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var serviceBus = builder.AddFlociAzure("floci-az")
+ .WithServiceBus(amqpPort: 5673, amqpTlsPort: 5674);
+
+ Assert.Equal(5673, serviceBus.Resource.AmqpPort);
+ Assert.Equal(5674, serviceBus.Resource.AmqpTlsPort);
+ }
+
+ [Fact]
+ public async Task WithServiceBusEnablesTheDataPlaneOnTheEmulator()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var azure = builder.AddFlociAzure("floci-az");
+ var serviceBus = azure.WithServiceBus(amqpPort: 5673, amqpTlsPort: 5674);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = appModel.Resources.OfType().Single();
+ Assert.True(resource.TryGetAnnotationsOfType(out IEnumerable? envAnnotations));
+
+ var envVars = new Dictionary();
+ var context = new EnvironmentCallbackContext(builder.ExecutionContext, envVars);
+ foreach (var annotation in envAnnotations!)
+ {
+ await annotation.Callback(context);
+ }
+
+ Assert.Equal("false", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED"].ToString());
+ Assert.Equal("true", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT"].ToString());
+ Assert.Equal("5673", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_PORT"].ToString());
+ Assert.Equal("5674", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_TLS_PORT"].ToString());
+ Assert.Equal(5673, serviceBus.Resource.AmqpPort);
+ }
+
+ [Fact]
+ public async Task ConnectionStringMatchesTheEmulatorShape()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var serviceBus = builder.AddFlociAzure("floci-az")
+ .WithServiceBus(amqpPort: 5673, amqpTlsPort: 5674);
+
+ string? connectionString = await serviceBus.Resource.ConnectionStringExpression
+ .GetValueAsync(CancellationToken.None);
+
+ Assert.Equal(
+ "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;" +
+ "SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;",
+ connectionString);
+ }
+
+ [Fact]
+ public void SecondWithServiceBusReturnsTheExistingChild()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var azure = builder.AddFlociAzure("floci-az");
+ var first = azure.WithServiceBus(amqpPort: 5673);
+ var second = azure.WithServiceBus();
+
+ Assert.Same(first.Resource, second.Resource);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ Assert.Single(appModel.Resources.OfType());
+ }
+
+ [Fact]
+ public void ConflictingPortsThrow()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var azure = builder.AddFlociAzure("floci-az");
+ azure.WithServiceBus(amqpPort: 5673);
+
+ Assert.Throws(() => azure.WithServiceBus(amqpPort: 5675));
+ }
+
+ [Fact]
+ public async Task WithReferenceInjectsTheConnectionString()
+ {
+ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();
+
+ var serviceBus = builder.AddFlociAzure("floci-az")
+ .WithServiceBus(amqpPort: 5673, amqpTlsPort: 5674);
+
+ var consumer = builder.AddContainer("api", "my-api-image")
+ .WithReference(serviceBus);
+
+ using var app = builder.Build();
+
+ Assert.True(consumer.Resource.TryGetAnnotationsOfType(
+ out IEnumerable? envAnnotations));
+
+ var envVars = new Dictionary();
+ var context = new EnvironmentCallbackContext(builder.ExecutionContext, envVars);
+ foreach (var annotation in envAnnotations!)
+ {
+ await annotation.Callback(context);
+ }
+
+ object connectionString = envVars["ConnectionStrings__servicebus"];
+ string? value = connectionString is IValueProvider provider
+ ? await provider.GetValueAsync(CancellationToken.None)
+ : connectionString.ToString();
+
+ Assert.Equal(
+ "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;" +
+ "SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;",
+ value);
+ }
+}