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); + } +}