Skip to content
Merged
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
<ItemGroup Label="Integration Packages">
<!-- External packages -->
<PackageVersion Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.20.1" />
<PackageVersion Include="Azure.Provisioning.AppContainers" Version="1.2.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.29.2" />
<PackageVersion Include="Google.Cloud.Storage.V1" Version="4.15.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +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';
Expand All @@ -9,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({
Expand All @@ -28,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,
Expand All @@ -51,7 +92,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.
Expand Down Expand Up @@ -82,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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string?> 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<object> References => [resource.Parent];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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. Container consumers reach those same ports through the container runtime's
/// host gateway. This resource is only supported in run mode.
/// </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;");
Comment on lines +56 to +58

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
@@ -1,3 +1,4 @@
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using CommunityToolkit.Aspire.Hosting.Floci;

Expand Down Expand Up @@ -73,6 +74,146 @@ 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. This API is run-only; call it inside an
/// <c>ExecutionContext.IsRunMode</c> branch when publishing the AppHost.
/// </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 globally unique name of the Service Bus resource (default: <c>servicebus</c>).
/// Pass a distinct name for each additional emulator.</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(
Comment thread
aaronpowell marked this conversation as resolved.
this IResourceBuilder<FlociAzureContainerResource> builder,
[ResourceName] string name = FlociAzureServiceBusResource.DefaultName,
int? amqpPort = null,
int? amqpTlsPort = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(name);

if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
throw new NotSupportedException(
"Floci Service Bus is only supported in run mode. Call WithServiceBus inside an ExecutionContext.IsRunMode branch and reference a deployable Service Bus resource when publishing.");
}

if (amqpPort is not null && amqpPort == amqpTlsPort)
{
throw new ArgumentException("AMQP and AMQPS must use different host ports.", nameof(amqpTlsPort));
}

FlociAzureServiceBusResource? existing = builder.ApplicationBuilder.Resources
.OfType<FlociAzureServiceBusResource>()
.FirstOrDefault(resource => resource.Parent == builder.Resource);
if (existing is not null)
{
if (!string.Equals(name, existing.Name, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Service Bus is already configured on '{builder.Resource.Name}' as '{existing.Name}' and cannot be reconfigured with a different name.");
}

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)
Comment thread
aaronpowell marked this conversation as resolved.
.WithEndpoint(
port: amqpPort,
scheme: "sb",
name: FlociAzureServiceBusResource.AmqpEndpointName,
isProxied: false)
.WithEndpoint(
port: amqpTlsPort,
scheme: "amqps",
name: FlociAzureServiceBusResource.AmqpTlsEndpointName,
isProxied: false)
.WithParentRelationship(builder)
.ExcludeFromManifest();

builder.WithEnvironment(context =>
{
if (context.ExecutionContext.IsPublishMode)
{
return;
}
Comment thread
aaronpowell marked this conversation as resolved.

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 Service Bus connection string reference, using the Docker host gateway for container consumers.
/// </summary>
/// <ats-summary>Adds a Floci Azure Service Bus reference</ats-summary>
/// <typeparam name="TDestination">The type of the resource receiving the reference.</typeparam>
/// <param name="builder">The resource builder receiving the reference.</param>
/// <param name="serviceBus">The Service Bus child resource to reference.</param>
/// <param name="connectionName">The connection string name, defaulting to the child resource name.</param>
/// <returns>The resource builder receiving the reference.</returns>
[AspireExport("withFlociAzureServiceBusReference")]
public static IResourceBuilder<TDestination> WithReference<TDestination>(
this IResourceBuilder<TDestination> builder,
IResourceBuilder<FlociAzureServiceBusResource> serviceBus,
string? connectionName = null)
where TDestination : IResourceWithEnvironment
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(serviceBus);

if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
throw new NotSupportedException("Floci Service Bus references are only supported in run mode.");
}

if (builder.Resource is not ContainerResource container)
{
return ResourceBuilderExtensions.WithReference(builder, serviceBus, connectionName);
}

builder.ApplicationBuilder.CreateResourceBuilder(container)
.WithContainerRuntimeArgs("--add-host", $"{FlociAzureServiceBusConnectionString.ContainerHost}:host-gateway");

return builder
.WithEnvironment(context =>
{
context.EnvironmentVariables[$"ConnectionStrings__{connectionName ?? serviceBus.Resource.Name}"] =
new FlociAzureServiceBusConnectionString(serviceBus.Resource);
})
.WithRelationship(serviceBus.Resource, "Reference");
}

/// <summary>
/// Adds a child resource representing the Cosmos DB API exposed by the Floci Azure emulator.
/// </summary>
Expand Down
Loading
Loading