diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs new file mode 100644 index 000000000..0ab1db5c7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureServiceBusResource.cs @@ -0,0 +1,66 @@ +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. The resource models those host ports as +/// proxyless Aspire endpoints so DCP can allocate them without trying to proxy traffic to the +/// parent container. +/// +/// The name of the resource. +/// The parent Floci Azure emulator resource. +[AspireExport(ExposeProperties = true)] +public class FlociAzureServiceBusResource( + string name, + FlociAzureContainerResource parent) : Resource(name), + IResourceWithParent, + IResourceWithConnectionString, + IResourceWithEndpoints +{ + internal const string DefaultName = "servicebus"; + internal const string AmqpEndpointName = "amqp"; + internal const string AmqpTlsEndpointName = "amqps"; + + private EndpointReference? _amqpEndpoint; + private EndpointReference? _amqpTlsEndpoint; + + // 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 Service Bus plain AMQP endpoint. + /// + public EndpointReference AmqpEndpoint => + _amqpEndpoint ??= new EndpointReference(this, AmqpEndpointName); + + /// + /// Gets the Service Bus AMQPS/TLS endpoint. + /// + public EndpointReference AmqpTlsEndpoint => + _amqpTlsEndpoint ??= new EndpointReference(this, AmqpTlsEndpointName); + + /// + /// 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={AmqpEndpoint};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey={DefaultSasKey};UseDevelopmentEmulator=true;"); + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() => + Parent.CombineProperties([ + new("Host", ReferenceExpression.Create($"{AmqpEndpoint.Property(EndpointProperty.Host)}")), + new("Port", ReferenceExpression.Create($"{AmqpEndpoint.Property(EndpointProperty.Port)}")), + new("Uri", ReferenceExpression.Create($"{AmqpEndpoint}")), + new("Endpoint", ReferenceExpression.Create($"{AmqpEndpoint}")) + ]); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociContainerImageTags.cs index e619061f7..46533afb7 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociContainerImageTags.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociContainerImageTags.cs @@ -8,7 +8,7 @@ internal static class FlociContainerImageTags public const string AzureRegistry = "docker.io"; public const string AzureImage = "floci/floci-az"; - public const string AzureTag = "0.11.0"; + public const string AzureTag = "0.12.0"; public const string GcpRegistry = "docker.io"; public const string GcpImage = "floci/floci-gcp"; diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs index 90ec94c08..1c763c6a8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Hosting.Floci; @@ -73,6 +74,85 @@ 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). + /// + /// + /// 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 + /// . + /// Aspire allocates proxyless AMQP host endpoints when ports are not specified. Requires + /// floci-az 0.12.0 or later. + /// + /// 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: allocated by Aspire). + /// Host port for AMQPS/TLS (default: allocated by Aspire). + /// 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); + + FlociAzureServiceBusResource? existing = builder.ApplicationBuilder.Resources + .OfType() + .FirstOrDefault(resource => resource.Parent == builder.Resource); + if (existing is not null) + { + int? existingAmqpPort = existing.AmqpEndpoint.EndpointAnnotation.Port; + int? existingAmqpTlsPort = existing.AmqpTlsEndpoint.EndpointAnnotation.Port; + if ((amqpPort is not null && amqpPort != existingAmqpPort) + || (amqpTlsPort is not null && amqpTlsPort != existingAmqpTlsPort)) + { + throw new InvalidOperationException( + $"Service Bus is already configured on '{builder.Resource.Name}' and cannot be reconfigured with different ports."); + } + + return builder.ApplicationBuilder.CreateResourceBuilder(existing); + } + + var serviceBus = new FlociAzureServiceBusResource(name, builder.Resource); + var serviceBusBuilder = builder.ApplicationBuilder + .AddResource(serviceBus) + .WithEndpoint( + port: amqpPort, + scheme: "sb", + name: FlociAzureServiceBusResource.AmqpEndpointName, + isProxied: false) + .WithEndpoint( + port: amqpTlsPort, + scheme: "amqps", + name: FlociAzureServiceBusResource.AmqpTlsEndpointName, + isProxied: false) + .WithParentRelationship(builder); + + builder.WithEnvironment(context => + { + if (context.ExecutionContext.IsPublishMode) + { + return; + } + + context.EnvironmentVariables["FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED"] = "false"; + context.EnvironmentVariables["FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT"] = "true"; + context.EnvironmentVariables["FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_PORT"] = + serviceBus.AmqpEndpoint.Port.ToString(CultureInfo.InvariantCulture); + context.EnvironmentVariables["FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_TLS_PORT"] = + serviceBus.AmqpTlsEndpoint.Port.ToString(CultureInfo.InvariantCulture); + }); + + return serviceBusBuilder; + } + /// /// Adds a child resource representing the Cosmos DB API exposed by the Floci Azure emulator. /// diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md index 7c35d914e..d9e73a88f 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md @@ -107,6 +107,38 @@ 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 **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(); +var serviceBus = azure.WithServiceBus(); + +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` 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. **GCP** 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..b60b5ba9b --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/AzureServiceBusResourceTests.cs @@ -0,0 +1,192 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.Floci.Tests; + +public class AzureServiceBusResourceTests +{ + [Fact] + public void WithServiceBusCreatesChildResourceWithAspireAllocatedEndpoints() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var azure = builder.AddFlociAzure("floci-az"); + azure.WithServiceBus(); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var serviceBus = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("servicebus", serviceBus.Name); + Assert.Same(azure.Resource, serviceBus.Parent); + + Assert.Collection( + serviceBus.Annotations.OfType().OrderBy(endpoint => endpoint.Name), + amqp => AssertEndpoint(amqp, "amqp", "sb", null), + amqps => AssertEndpoint(amqps, "amqps", "amqps", null)); + } + + [Fact] + public void WithServiceBusHonorsExplicitPorts() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var serviceBus = builder.AddFlociAzure("floci-az") + .WithServiceBus(amqpPort: 5673, amqpTlsPort: 5674); + + Assert.Equal(5673, serviceBus.Resource.AmqpEndpoint.EndpointAnnotation.Port); + Assert.Equal(5674, serviceBus.Resource.AmqpTlsEndpoint.EndpointAnnotation.Port); + } + + [Fact] + public async Task WithServiceBusUsesAllocatedEndpointPortsForTheDataPlane() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var azure = builder.AddFlociAzure("floci-az"); + var serviceBus = azure.WithServiceBus(); + AllocateEndpoints(serviceBus.Resource, 5673, 5674); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + 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"]); + Assert.Equal("true", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT"]); + Assert.Equal("5673", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_PORT"]); + Assert.Equal("5674", envVars["FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_TLS_PORT"]); + } + + [Fact] + public async Task WithServiceBusDoesNotConfigureTheDataPlaneInPublishMode() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var azure = builder.AddFlociAzure("floci-az"); + azure.WithServiceBus(); + + var envVars = new Dictionary(); + var executionContext = new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish)); + var context = new EnvironmentCallbackContext(executionContext, envVars); + + foreach (var annotation in azure.Resource.Annotations.OfType()) + { + await annotation.Callback(context); + } + + Assert.DoesNotContain("FLOCI_AZ_SERVICES_SERVICE_BUS_MOCKED", envVars); + Assert.DoesNotContain("FLOCI_AZ_SERVICES_SERVICE_BUS_START_ON_BOOT", envVars); + Assert.DoesNotContain("FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_PORT", envVars); + Assert.DoesNotContain("FLOCI_AZ_SERVICES_SERVICE_BUS_AMQP_TLS_PORT", envVars); + } + + [Fact] + public async Task ConnectionStringMatchesTheEmulatorShape() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var serviceBus = builder.AddFlociAzure("floci-az").WithServiceBus(); + AllocateEndpoints(serviceBus.Resource, 5673, 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(amqpPort: 5673); + + 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(); + AllocateEndpoints(serviceBus.Resource, 5673, 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); + } + + private static void AssertEndpoint( + EndpointAnnotation endpoint, + string name, + string scheme, + int? port) + { + Assert.Equal(name, endpoint.Name); + Assert.Equal(scheme, endpoint.UriScheme); + Assert.Equal(port, endpoint.Port); + Assert.False(endpoint.IsExplicitlyProxied); + } + + private static void AllocateEndpoints( + FlociAzureServiceBusResource serviceBus, + int amqpPort, + int amqpTlsPort) + { + serviceBus.AmqpEndpoint.EndpointAnnotation.AllocatedEndpoint = + new AllocatedEndpoint(serviceBus.AmqpEndpoint.EndpointAnnotation, "localhost", amqpPort); + serviceBus.AmqpTlsEndpoint.EndpointAnnotation.AllocatedEndpoint = + new AllocatedEndpoint(serviceBus.AmqpTlsEndpoint.EndpointAnnotation, "localhost", amqpTlsPort); + } +}