Skip to content
Draft
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
@@ -0,0 +1,66 @@
namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Represents the Service Bus AMQP data plane exposed by a Floci Azure emulator resource.
/// </summary>
/// <remarks>
/// 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.
/// </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 FlociAzureServiceBusResource(
string name,
FlociAzureContainerResource parent) : Resource(name),
IResourceWithParent<FlociAzureContainerResource>,
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";

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

/// <summary>
/// Gets the Service Bus plain AMQP endpoint.
/// </summary>
public EndpointReference AmqpEndpoint =>
_amqpEndpoint ??= new EndpointReference(this, AmqpEndpointName);

/// <summary>
/// Gets the Service Bus AMQPS/TLS endpoint.
/// </summary>
public EndpointReference AmqpTlsEndpoint =>
_amqpTlsEndpoint ??= new EndpointReference(this, AmqpTlsEndpointName);

/// <summary>
/// Gets the Service Bus connection string expression.
/// <c>UseDevelopmentEmulator=true</c> makes the Azure SDKs use plain AMQP (no TLS), matching
/// the official Service Bus emulator's connection-string shape.
/// </summary>
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create(
$"Endpoint={AmqpEndpoint};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey={DefaultSasKey};UseDevelopmentEmulator=true;");

IEnumerable<KeyValuePair<string, ReferenceExpression>> 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}"))
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using CommunityToolkit.Aspire.Hosting.Floci;

Expand Down Expand Up @@ -73,6 +74,85 @@ public static IResourceBuilder<TDestination> WithReference<TDestination>(

});

/// <summary>
/// Adds a child resource representing the Service Bus AMQP data plane exposed by the Floci
/// Azure emulator, enabling the data plane on the emulator (<c>MOCKED=false</c>,
/// <c>START_ON_BOOT=true</c>).
/// </summary>
/// <remarks>
/// Reference the returned resource with Aspire's standard <c>WithReference</c> API to inject
/// its Service Bus connection string (e.g. for <c>AddAzureServiceBusClient</c>). The Artemis
/// sidecar is a separate container floci-az starts via Docker, so the emulator also needs
/// <see cref="WithDockerSocket(IResourceBuilder{FlociAzureContainerResource}, string)"/>.
/// Aspire allocates proxyless AMQP host endpoints when ports are not specified. Requires
/// floci-az 0.12.0 or later.
/// </remarks>
/// <ats-summary>Adds a Service Bus 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 Service Bus resource (default: <c>servicebus</c>).</param>
/// <param name="amqpPort">Host port for plain AMQP (default: allocated by Aspire).</param>
/// <param name="amqpTlsPort">Host port for AMQPS/TLS (default: allocated by Aspire).</param>
/// <returns>A reference to the <see cref="IResourceBuilder{FlociAzureServiceBusResource}"/> for further configuration.</returns>
[AspireExport]
public static IResourceBuilder<FlociAzureServiceBusResource> WithServiceBus(
this IResourceBuilder<FlociAzureContainerResource> builder,
[ResourceName] string name = FlociAzureServiceBusResource.DefaultName,
int? amqpPort = null,
int? amqpTlsPort = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);

FlociAzureServiceBusResource? existing = builder.ApplicationBuilder.Resources
.OfType<FlociAzureServiceBusResource>()
.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;
}

/// <summary>
/// Adds a child resource representing the Cosmos DB API exposed by the Floci Azure emulator.
/// </summary>
Expand Down
32 changes: 32 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,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<MyApi>("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**

Expand Down
Original file line number Diff line number Diff line change
@@ -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<DistributedApplicationModel>();

var serviceBus = Assert.Single(appModel.Resources.OfType<FlociAzureServiceBusResource>());
Assert.Equal("servicebus", serviceBus.Name);
Assert.Same(azure.Resource, serviceBus.Parent);

Assert.Collection(
serviceBus.Annotations.OfType<EndpointAnnotation>().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<DistributedApplicationModel>();

var resource = Assert.Single(appModel.Resources.OfType<FlociAzureContainerResource>());
Assert.True(resource.TryGetAnnotationsOfType(out IEnumerable<EnvironmentCallbackAnnotation>? envAnnotations));

var envVars = new Dictionary<string, object>();
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<string, object>();
var executionContext = new DistributedApplicationExecutionContext(
new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish));
var context = new EnvironmentCallbackContext(executionContext, envVars);

foreach (var annotation in azure.Resource.Annotations.OfType<EnvironmentCallbackAnnotation>())
{
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<DistributedApplicationModel>();
Assert.Single(appModel.Resources.OfType<FlociAzureServiceBusResource>());
}

[Fact]
public void ConflictingPortsThrow()
{
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder();

var azure = builder.AddFlociAzure("floci-az");
azure.WithServiceBus(amqpPort: 5673);

Assert.Throws<InvalidOperationException>(() => 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<EnvironmentCallbackAnnotation>? envAnnotations));

var envVars = new Dictionary<string, object>();
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);
}
}
Loading