Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom application model for EventHubs emulator #7005

Merged
merged 14 commits into from
Jan 10, 2025
2 changes: 1 addition & 1 deletion playground/AspireEventHub/EventHubs.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

var eventHub = builder.AddAzureEventHubs("eventhubns")
.RunAsEmulator()
.AddEventHub("hub");
.WithHub("hub");

builder.AddProject<Projects.EventHubsConsumer>("consumer")
.WithReference(eventHub).WaitFor(eventHub)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
var storage = builder.AddAzureStorage("storage").RunAsEmulator();
var queue = storage.AddQueues("queue");
var blob = storage.AddBlobs("blob");
var eventHubs = builder.AddAzureEventHubs("eventhubs").RunAsEmulator().AddEventHub("myhub");
var eventHubs = builder.AddAzureEventHubs("eventhubs").RunAsEmulator().WithHub("myhub");

#if !SKIP_PROVISIONED_AZURE_RESOURCE
var serviceBus = builder.AddAzureServiceBus("messaging").AddQueue("myqueue");
Expand Down
180 changes: 140 additions & 40 deletions src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
using Aspire.Hosting.Utils;
using Azure.Messaging.EventHubs.Producer;
using Azure.Provisioning;
using Azure.Provisioning.EventHubs;
using AzureProvisioning = Azure.Provisioning.EventHubs;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Nodes;

namespace Aspire.Hosting;

Expand Down Expand Up @@ -37,9 +38,9 @@ public static IResourceBuilder<AzureEventHubsResource> AddAzureEventHubs(
};
infrastructure.Add(skuParameter);

var eventHubsNamespace = new EventHubsNamespace(infrastructure.AspireResource.GetBicepIdentifier())
var eventHubsNamespace = new AzureProvisioning.EventHubsNamespace(infrastructure.AspireResource.GetBicepIdentifier())
{
Sku = new EventHubsSku()
Sku = new AzureProvisioning.EventHubsSku()
{
Name = skuParameter
},
Expand All @@ -52,20 +53,24 @@ public static IResourceBuilder<AzureEventHubsResource> AddAzureEventHubs(
var principalIdParameter = new ProvisioningParameter(AzureBicepResource.KnownParameters.PrincipalId, typeof(string));
infrastructure.Add(principalIdParameter);

infrastructure.Add(eventHubsNamespace.CreateRoleAssignment(EventHubsBuiltInRole.AzureEventHubsDataOwner, principalTypeParameter, principalIdParameter));
infrastructure.Add(eventHubsNamespace.CreateRoleAssignment(AzureProvisioning.EventHubsBuiltInRole.AzureEventHubsDataOwner, principalTypeParameter, principalIdParameter));

infrastructure.Add(new ProvisioningOutput("eventHubsEndpoint", typeof(string)) { Value = eventHubsNamespace.ServiceBusEndpoint });

var azureResource = (AzureEventHubsResource)infrastructure.AspireResource;

foreach (var hub in azureResource.Hubs)
{
var hubResource = new EventHub(Infrastructure.NormalizeBicepIdentifier(hub))
var cdkHub = hub.ToProvisioningEntity();
cdkHub.Parent = eventHubsNamespace;
infrastructure.Add(cdkHub);

foreach (var consumerGroup in hub.ConsumerGroups)
{
Parent = eventHubsNamespace,
Name = hub
};
infrastructure.Add(hubResource);
var cdkConsumerGroup = consumerGroup.ToProvisioningEntity();
cdkConsumerGroup.Parent = cdkHub;
infrastructure.Add(cdkConsumerGroup);
}
}
};

Expand All @@ -80,9 +85,34 @@ public static IResourceBuilder<AzureEventHubsResource> AddAzureEventHubs(
/// <param name="builder">The Azure Event Hubs resource builder.</param>
/// <param name="name">The name of the Event Hub.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
[Obsolete($"This method is obsolete and will be removed in a future version. Use {nameof(WithHub)} instead to add an Azure Event Hub.")]
public static IResourceBuilder<AzureEventHubsResource> AddEventHub(this IResourceBuilder<AzureEventHubsResource> builder, [ResourceName] string name)
{
builder.Resource.Hubs.Add(name);
return builder.WithHub(name);
}

/// <summary>
/// Adds an Azure Event Hubs hub resource to the application model. This resource requires an <see cref="AzureEventHubsResource"/> to be added to the application model.
/// </summary>
/// <remarks>
/// This version of the package defaults to the <inheritdoc cref="EventHubsEmulatorContainerImageTags.Tag"/> tag of the <inheritdoc cref="EventHubsEmulatorContainerImageTags.Registry"/>/<inheritdoc cref="EventHubsEmulatorContainerImageTags.Image"/> container image.
/// </remarks>
/// <param name="builder">The Azure Event Hubs resource builder.</param>
/// <param name="name">The name of the Event Hub.</param>
/// <param name="configure">An optional method that can be used for customizing the <see cref="EventHub"/>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<AzureEventHubsResource> WithHub(this IResourceBuilder<AzureEventHubsResource> builder, [ResourceName] string name, Action<EventHub>? configure = null)
{
var hub = builder.Resource.Hubs.FirstOrDefault(x => x.Name == name);

if (hub == null)
{
hub = new EventHub(name);
builder.Resource.Hubs.Add(hub);
}

configure?.Invoke(hub);

return builder;
}

Expand Down Expand Up @@ -118,15 +148,13 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou
return builder;
}

// Add emulator container
var configHostFile = Path.GetTempFileName();
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(configHostFile,
UnixFileMode.UserRead | UnixFileMode.UserWrite
| UnixFileMode.GroupRead | UnixFileMode.GroupWrite
| UnixFileMode.OtherRead | UnixFileMode.OtherWrite);
}
var configHostFile = Path.Combine(Directory.CreateTempSubdirectory("AspireEventHubsEmulator").FullName, "Config.json");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eerhardt Longer term we should have a service for doing this that gets cleaned up on exit. @karolz-ms maybe something DCP can provide since it controls the clean up.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely. For example, I can imagine us having the capability for Containers where you can say "here is a blob of JSON that I need to appear as file inside the container, under this path". Sort of like Kubernetes ConfigMaps.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #7029 to track.


var customMountAnnotation = new ContainerMountAnnotation(
configHostFile,
AzureEventHubsEmulatorResource.EmulatorConfigJsonPath,
ContainerMountType.BindMount,
isReadOnly: false);
sebastienros marked this conversation as resolved.
Show resolved Hide resolved

builder
.WithEndpoint(name: "emulator", targetPort: 5672)
Expand All @@ -136,11 +164,7 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou
Image = EventHubsEmulatorContainerImageTags.Image,
Tag = EventHubsEmulatorContainerImageTags.Tag
})
.WithAnnotation(new ContainerMountAnnotation(
configHostFile,
AzureEventHubsEmulatorResource.EmulatorConfigJsonPath,
ContainerMountType.BindMount,
isReadOnly: false));
.WithAnnotation(customMountAnnotation);

// Create a separate storage emulator for the Event Hub one
var storageResource = builder.ApplicationBuilder
Expand Down Expand Up @@ -169,9 +193,9 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou
// For the purposes of the health check we only need to know a hub name. If we don't have a hub
// name we can't configure a valid producer client connection so we should throw. What good is
// an event hub namespace without an event hub? :)
if (builder.Resource.Hubs is { Count: > 0 } && builder.Resource.Hubs[0] is string hub)
if (builder.Resource.Hubs is { Count: > 0 } && builder.Resource.Hubs[0] is { } hub)
{
var healthCheckConnectionString = $"{connectionString};EntityPath={hub};";
var healthCheckConnectionString = $"{connectionString};EntityPath={hub.Name};";
client = new EventHubProducerClient(healthCheckConnectionString);
}
else
Expand Down Expand Up @@ -207,30 +231,39 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou

foreach (var emulatorResource in eventHubsEmulatorResources)
{
var configFileMount = emulatorResource.Annotations.OfType<ContainerMountAnnotation>().Single(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath);
// A custom file mount with read-only access is used to mount the emulator configuration file. If it's not found, the read-write mount we defined on the container is used.
var configFileMount = emulatorResource.Annotations.OfType<ContainerMountAnnotation>().LastOrDefault(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath);

// If the latest mount for EmulatorConfigJsonPath is our custom one then we can generate it.
if (configFileMount != customMountAnnotation)
{
continue;
}
using var stream = new FileStream(configFileMount.Source!, FileMode.Create);
using var writer = new Utf8JsonWriter(stream);
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });

if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(configHostFile,
UnixFileMode.UserRead | UnixFileMode.UserWrite
| UnixFileMode.GroupRead | UnixFileMode.GroupWrite
| UnixFileMode.OtherRead | UnixFileMode.OtherWrite);
}

writer.WriteStartObject(); // {
writer.WriteStartObject("UserConfig"); // "UserConfig": {
writer.WriteStartArray("NamespaceConfig"); // "NamespaceConfig": [
writer.WriteStartObject(); // {
writer.WriteString("Type", "EventHub"); // "Type": "EventHub",
writer.WriteString("Type", "EventHub");

// This name is currently required by the emulator
writer.WriteString("Name", "emulatorNs1"); // "Name": "emulatorNs1"
writer.WriteString("Name", "emulatorNs1");
writer.WriteStartArray("Entities"); // "Entities": [

foreach (var hub in emulatorResource.Hubs)
{
// The default consumer group ('$default') is automatically created

writer.WriteStartObject(); // {
writer.WriteString("Name", hub); // "Name": "hub",
writer.WriteString("PartitionCount", "2"); // "PartitionCount": "2",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to have "2" as the default, but hub.WriteJsonObjectProperties now writes "1" by default.

Is this intentional?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR it was 2 because of the sample they provided. But since the schema states [1-32] I decided to use 1 when the value is not set explicitly.

writer.WriteStartArray("ConsumerGroups"); // "ConsumerGroups": [
writer.WriteEndArray(); // ]
writer.WriteStartObject();
hub.WriteJsonObjectProperties(writer);
writer.WriteEndObject(); // }
}

Expand All @@ -246,8 +279,38 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou

}

return Task.CompletedTask;
// Apply ConfigJsonAnnotation modifications
foreach (var emulatorResource in eventHubsEmulatorResources)
{
var configFileMount = emulatorResource.Annotations.OfType<ContainerMountAnnotation>().LastOrDefault(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath);

// At this point there should be a mount for the Config.json file.
if (configFileMount == null)
{
throw new InvalidOperationException("The configuration file mount is not set.");
}

var configJsonAnnotations = emulatorResource.Annotations.OfType<ConfigJsonAnnotation>();

foreach (var annotation in configJsonAnnotations)
{
using var readStream = new FileStream(configFileMount.Source!, FileMode.Open, FileAccess.Read);
var jsonObject = JsonNode.Parse(readStream);
readStream.Close();

using var writeStream = new FileStream(configFileMount.Source!, FileMode.Open, FileAccess.Write);
using var writer = new Utf8JsonWriter(writeStream, new JsonWriterOptions { Indented = true });

if (jsonObject == null)
{
throw new InvalidOperationException("The configuration file mount could not be parsed.");
}
annotation.Configure(jsonObject);
jsonObject.WriteTo(writer);
}
}

return Task.CompletedTask;
});

return builder;
Expand All @@ -272,16 +335,53 @@ public static IResourceBuilder<AzureEventHubsEmulatorResource> WithDataVolume(th
=> builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/data", isReadOnly: false);

/// <summary>
/// Configures the gateway port for the Azure Event Hubs emulator.
/// Configures the host port for the Azure Event Hubs emulator is exposed on instead of using randomly assigned port.
/// </summary>
/// <param name="builder">Builder for the Azure Event Hubs emulator container</param>
/// <param name="port">Host port to bind to the emulator gateway port.</param>
/// <param name="port">The port to bind on the host. If <see langword="null"/> is used, a random port will be assigned.</param>
/// <returns>Azure Event Hubs emulator resource builder.</returns>
[Obsolete("Use WithHostPort instead.")]
public static IResourceBuilder<AzureEventHubsEmulatorResource> WithGatewayPort(this IResourceBuilder<AzureEventHubsEmulatorResource> builder, int? port)
{
return WithHostPort(builder, port);
}

/// <summary>
/// Configures the host port for the Azure Event Hubs emulator is exposed on instead of using randomly assigned port.
/// </summary>
/// <param name="builder">Builder for the Azure Event Hubs emulator container</param>
/// <param name="port">The port to bind on the host. If <see langword="null"/> is used, a random port will be assigned.</param>
/// <returns>Azure Event Hubs emulator resource builder.</returns>
public static IResourceBuilder<AzureEventHubsEmulatorResource> WithHostPort(this IResourceBuilder<AzureEventHubsEmulatorResource> builder, int? port)
{
return builder.WithEndpoint("emulator", endpoint =>
{
endpoint.Port = port;
});
}

/// <summary>
/// Adds a bind mount for the configuration file of an Azure Service Bus emulator resource.
/// </summary>
/// <param name="builder">The builder for the <see cref="AzureEventHubsEmulatorResource"/>.</param>
/// <param name="path">Path to the file on the AppHost where the emulator configuration is located.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<AzureEventHubsEmulatorResource> WithConfigurationFile(this IResourceBuilder<AzureEventHubsEmulatorResource> builder, string path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a test for this method?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was also missing in SB, so I added it. At the same time I tried to simplify the implementation. However I intentionally didn't add support for a custom file provided for config, AND configuration hooks. I would assume if you provide a file, you could have updated it yourself. Hooks are useful when the emulator is generating a file from the resource model and you can't update some section from this model.

=> builder.WithBindMount(path, AzureEventHubsEmulatorResource.EmulatorConfigJsonPath, isReadOnly: false);
sebastienros marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Alters the JSON configuration document used by the emulator.
/// </summary>
/// <param name="builder">The builder for the <see cref="AzureEventHubsEmulatorResource"/>.</param>
/// <param name="configJson">A callback to update the JSON object representation of the configuration.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<AzureEventHubsEmulatorResource> ConfigureEmulator(this IResourceBuilder<AzureEventHubsEmulatorResource> builder, Action<JsonNode> configJson)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configJson);

builder.WithAnnotation(new ConfigJsonAnnotation(configJson));

return builder;
}
}
3 changes: 2 additions & 1 deletion src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.EventHubs;

namespace Aspire.Hosting.Azure;

Expand All @@ -27,7 +28,7 @@ public class AzureEventHubsResource(string name, Action<AzureResourceInfrastruct

private const string ConnectionKeyPrefix = "Aspire__Azure__Messaging__EventHubs";

internal List<string> Hubs { get; } = [];
internal List<EventHub> Hubs { get; } = [];

/// <summary>
/// Gets the "eventHubsEndpoint" output reference from the bicep template for the Azure Event Hubs resource.
Expand Down
20 changes: 20 additions & 0 deletions src/Aspire.Hosting.Azure.EventHubs/ConfigJsonAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Nodes;
using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Azure.EventHubs;

/// <summary>
/// Represents an annotation for updating the JSON content of a mounted document.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "a mounted document" mean?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Config.json file (json document) that is mounted to the container using ContainerMountType.BindMount.

/// </summary>
internal sealed class ConfigJsonAnnotation : IResourceAnnotation
{
public ConfigJsonAnnotation(Action<JsonNode> configure)
{
Configure = configure;
}

public Action<JsonNode> Configure { get; }
}
Loading
Loading