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 1/4] 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);
+ }
+}
From ff19d6f964c983be2f78f522c9fe5a84e25ce7bc Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:33:23 +0100
Subject: [PATCH 2/4] test(floci): exercise Service Bus SDK against live
emulator
---
Directory.Packages.props | 1 +
.../README.md | 2 +
.../AzureServiceBusFunctionalTests.cs | 85 +++++++++++++++++++
...yToolkit.Aspire.Hosting.Floci.Tests.csproj | 1 +
4 files changed, 89 insertions(+)
create mode 100644 tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusFunctionalTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 19798e63e..0086f18ae 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -79,6 +79,7 @@
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
index d9e73a88f..b60f5f7b2 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md
@@ -140,6 +140,8 @@ builder.AddAzureServiceBusClient("servicebus");
`WithServiceBus` sets `FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED=false` and `FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT=true`. Aspire models the sidecar's AMQP and AMQPS host ports as proxyless endpoints and allocates them by default; pass `amqpPort` / `amqpTlsPort` to use fixed ports. The management plane (for example, `ServiceBusAdministrationClient`) remains on the base endpoint from `WithReference(azure)`. Requires `WithDockerSocket()` and floci-az 0.12.0 or later.
+When running multiple Floci Azure emulators on the same Docker host, set a different `FLOCI_AZ_DOCKER_RESOURCE_NAMESPACE` environment variable on each emulator to keep their sidecar container names separate. Port allocation alone does not isolate those names.
+
**GCP**
```csharp
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusFunctionalTests.cs
new file mode 100644
index 000000000..a13c5ac6d
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusFunctionalTests.cs
@@ -0,0 +1,85 @@
+using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+using Azure.Messaging.ServiceBus;
+using Azure.Messaging.ServiceBus.Administration;
+using CommunityToolkit.Aspire.Testing;
+using System.Net.Sockets;
+
+namespace CommunityToolkit.Aspire.Hosting.Floci.Tests;
+
+[RequiresDocker]
+public class AzureServiceBusFunctionalTests(ITestOutputHelper testOutputHelper)
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ServiceBusStartsAndSupportsSdkSendAndReceive(bool useBatch)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromMinutes(3));
+ var cancellationToken = timeout.Token;
+
+ using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
+ string resourceNamespace = $"test-{Guid.NewGuid():N}";
+ var azure = builder.AddFlociAzure("floci-az")
+ .WithDockerSocket()
+ .WithEnvironment("FLOCI_AZ_DOCKER_RESOURCE_NAMESPACE", resourceNamespace);
+ var serviceBus = azure.WithServiceBus();
+ var consumer = builder.AddExecutable("consumer", "dotnet", builder.AppHostDirectory, "--version")
+ .WithReference(serviceBus)
+ .WaitFor(azure);
+
+ await using var app = await builder.BuildAsync(cancellationToken);
+ await app.StartAsync(cancellationToken);
+ await app.ResourceNotifications.WaitForResourceHealthyAsync(azure.Resource.Name, cancellationToken);
+
+ Assert.NotEqual(serviceBus.Resource.AmqpEndpoint.Port, serviceBus.Resource.AmqpTlsEndpoint.Port);
+
+ // Both listeners must exist before any management call can lazily start the namespace.
+ foreach (var endpoint in new[] { serviceBus.Resource.AmqpEndpoint, serviceBus.Resource.AmqpTlsEndpoint })
+ {
+ using var socket = new TcpClient();
+ await socket.ConnectAsync(endpoint.Host, endpoint.Port, cancellationToken);
+ }
+
+ var environment = await consumer.Resource.GetEnvironmentVariablesAsync(serviceProvider: app.Services)
+ .AsTask().WaitAsync(cancellationToken);
+ string connectionString = environment["ConnectionStrings__servicebus"];
+ Assert.Equal(await app.GetConnectionStringAsync(serviceBus.Resource.Name, cancellationToken), connectionString);
+ var managementEndpoint = new Uri(azure.Resource.PrimaryEndpoint.Url);
+ var administration = new ServiceBusAdministrationClient(
+ $"Endpoint=sb://{managementEndpoint.Authority};SharedAccessKeyName=RootManageSharedAccessKey;" +
+ "SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;");
+ string queueName = $"test-{Guid.NewGuid():N}";
+ await administration.CreateQueueAsync(queueName, cancellationToken);
+
+ await using var client = new ServiceBusClient(connectionString);
+ await using var sender = client.CreateSender(queueName);
+ string[] bodies = useBatch ? ["first", "second", "third"] : ["single"];
+ if (useBatch)
+ {
+ using var batch = await sender.CreateMessageBatchAsync(cancellationToken);
+ foreach (string body in bodies)
+ {
+ Assert.True(batch.TryAddMessage(new ServiceBusMessage(body)));
+ }
+
+ await sender.SendMessagesAsync(batch, cancellationToken);
+ }
+ else
+ {
+ await sender.SendMessageAsync(new ServiceBusMessage(bodies[0]), cancellationToken);
+ }
+
+ await using var receiver = client.CreateReceiver(queueName);
+ foreach (string body in bodies)
+ {
+ var message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(20), cancellationToken);
+ Assert.NotNull(message);
+ Assert.Equal(body, message.Body.ToString());
+ await receiver.CompleteMessageAsync(message, cancellationToken);
+ }
+
+ await administration.DeleteQueueAsync(queueName, 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 f408eb6d6..da8f129e8 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
@@ -13,6 +13,7 @@
+
From b390215e23d1d38bc78cdcd99b5088c8aac30a7b Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:39:28 +0100
Subject: [PATCH 3/4] fix(floci): create portable sample bind mount directory
---
.../apphost.mts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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 4c498a58d..21e4bd7ca 100644
--- a/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
+++ b/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
@@ -1,3 +1,4 @@
+import { mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createBuilder } from './.aspire/modules/aspire.mjs';
@@ -51,7 +52,9 @@ await flociPersistent.withDataVolume('floci-data');
// ── Persistent storage — bind mount ───────────────────────────────────────────
const flociMount = await builder.addFlociAws('floci-mount');
-await flociMount.withDataBindMount('/tmp/floci-data');
+const flociDataPath = path.join(appHostDirectory, '.aspire', 'floci-data');
+mkdirSync(flociDataPath, { recursive: true });
+await flociMount.withDataBindMount(flociDataPath);
// ── Compile-time coverage ─────────────────────────────────────────────────────
// Guards with false so these are type-checked but never executed.
From 923074183e3890b31bc86b1c28a27660f15a14f5 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:06:28 +0100
Subject: [PATCH 4/4] fix(floci): address Service Bus review feedback
---
.../apphost.mts | 41 ++++++++
.../FlociAzureServiceBusConnectionString.cs | 21 ++++
.../FlociAzureServiceBusResource.cs | 3 +-
.../FlociHostingExtension.Azure.cs | 67 ++++++++++++-
.../README.md | 14 ++-
.../AzureServiceBusFunctionalTests.cs | 31 ++++++
.../AzureServiceBusResourceTests.cs | 96 ++++++++++++++++++-
.../TypeScriptAppHostTests.cs | 2 +-
8 files changed, 262 insertions(+), 13 deletions(-)
create mode 100644 src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusConnectionString.cs
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 21e4bd7ca..0a9f7ccce 100644
--- a/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
+++ b/examples/floci/CommunityToolkit.Aspire.Hosting.Floci.AppHost.TypeScript/apphost.mts
@@ -1,4 +1,5 @@
import { mkdirSync } from "node:fs";
+import { randomUUID } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createBuilder } from './.aspire/modules/aspire.mjs';
@@ -10,6 +11,13 @@ const flociAws = await builder.addFlociAws('floci-aws');
const flociAzure = await builder.addFlociAzure('floci-az');
const flociGcp = await builder.addFlociGcp('floci-gcp');
+await flociAzure.withDockerSocket();
+await flociAzure.withEnvironment('FLOCI_AZ_DOCKER_RESOURCE_NAMESPACE', `sample-${randomUUID()}`);
+const serviceBus = await flociAzure.withServiceBus({ name: 'servicebus' });
+await serviceBus.amqpEndpoint();
+await serviceBus.amqpTlsEndpoint();
+await serviceBus.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.
await flociAws.withFlociUI({
@@ -29,10 +37,42 @@ const apiService = await builder.addProject("floci-api", apiServiceProjectPath)
.withFlociAwsReference(flociAws)
.withFlociAzureReference(flociAzure)
.withFlociGcpReference(flociGcp)
+ .withFlociAzureServiceBusReference(serviceBus)
.waitFor(flociAws)
.waitFor(flociAzure)
.waitFor(flociGcp);
+// Resolve the returned child handle from a real container and require an AMQP response.
+await builder.addContainer('servicebus-probe', 'node:22-alpine')
+ .withFlociAzureServiceBusReference(serviceBus)
+ .withHttpEndpoint({ targetPort: 8080 })
+ .withHttpHealthCheck({ path: '/' })
+ .waitFor(flociAzure)
+ .withArgs(['-e', `
+ const net = require('node:net');
+ const http = require('node:http');
+ const assert = require('node:assert/strict');
+ const connectionString = process.env.ConnectionStrings__servicebus;
+ const endpoint = new URL(connectionString.split(';')[0].slice('Endpoint='.length));
+ const header = Buffer.from([65, 77, 81, 80, 0, 1, 0, 0]);
+ let ready = false;
+ const socket = net.connect({ host: endpoint.hostname, port: Number(endpoint.port) }, () => socket.write(header));
+ socket.setTimeout(20000, () => { throw new Error('AMQP handshake timed out'); });
+ let response = Buffer.alloc(0);
+ socket.on('data', data => {
+ response = Buffer.concat([response, data]);
+ if (response.length >= header.length) {
+ assert.deepEqual(response.subarray(0, header.length), header);
+ ready = true;
+ socket.destroy();
+ }
+ });
+ http.createServer((request, response) => {
+ response.writeHead(ready ? 200 : 503);
+ response.end();
+ }).listen(8080, '0.0.0.0');
+ `]);
+
// ── Custom port and region ────────────────────────────────────────────────────
await builder.addFlociAws('floci-custom', {
port: 14566,
@@ -85,6 +125,7 @@ if (includeCompileOnlyScenarios) {
await _azurePodman.withDockerSocket({
socketPath: '/run/user/1000/podman/podman.sock'
});
+ await _azurePodman.withServiceBus({ name: 'podman-servicebus', amqpPort: 15673, amqpTlsPort: 15674 });
const _gcpPodman = await builder.addFlociGcp('floci-gcp-podman');
await _gcpPodman.withDockerSocket({
diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusConnectionString.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusConnectionString.cs
new file mode 100644
index 000000000..860888705
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusConnectionString.cs
@@ -0,0 +1,21 @@
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Floci;
+
+// The sidecar publishes directly on the Docker host. Expose the parent as the dependency
+// so Aspire does not create a container tunnel for the child's proxyless listeners.
+internal sealed class FlociAzureServiceBusConnectionString(FlociAzureServiceBusResource resource)
+ : IValueProvider, IManifestExpressionProvider, IValueWithReferences
+{
+ internal const string ContainerHost = "floci-servicebus-host.internal";
+
+ public async ValueTask GetValueAsync(CancellationToken cancellationToken = default)
+ {
+ string? port = await resource.AmqpEndpoint.Property(EndpointProperty.Port).GetValueAsync(cancellationToken);
+ return $"Endpoint=sb://{ContainerHost}:{port};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey={FlociAzureServiceBusResource.DefaultSasKey};UseDevelopmentEmulator=true;";
+ }
+
+ public string ValueExpression => throw new NotSupportedException("Floci Service Bus references are only supported in run mode.");
+
+ public IEnumerable