diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index f6c61bf3c..4d9e4a337 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -162,6 +162,9 @@ + + + @@ -255,6 +258,7 @@ + @@ -324,6 +328,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 427c978a1..a295ff937 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -52,5 +52,7 @@ + + diff --git a/README.md b/README.md index a875784ea..8f5114a58 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col | - **Learn More**: [`Hosting.Redis.Extensions`][redis-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Redis.Extensions][redis-ext-shields]][redis-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Redis.Extensions][redis-ext-shields-preview]][redis-ext-nuget-preview] | An integration that contains some additional extensions for hosting Redis container. | | - **Learn More**: [`Hosting.SqlServer.Extensions`][sqlserver-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.SqlServer.Extensions][sqlserver-ext-shields]][sqlserver-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.SqlServer.Extensions][sqlserver-ext-shields-preview]][sqlserver-ext-nuget-preview] | An integration that contains some additional extensions for hosting SqlServer container. | | - **Learn More**: [`Hosting.LavinMQ`][lavinmq-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.LavinMQ][lavinmq-shields]][lavinmq-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.LavinMQ][lavinmq-shields-preview]][lavinmq-nuget-preview] | An Aspire hosting integration for [LavinMQ](https://www.lavinmq.com). | +| - **Learn More**: [`Hosting.RedPanda`][redpanda-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.RedPanda][redpanda-shields]][redpanda-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.RedPanda][redpanda-shields-preview]][redpanda-nuget-preview] | An Aspire hosting integration for the [Redpanda](https://www.redpanda.com/) Kafka-compatible streaming platform. | | - **Learn More**: [`Hosting.MailPit`][mailpit-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.MailPit][mailpit-ext-shields]][mailpit-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.MailPit][mailpit-ext-shields-preview]][mailpit-ext-nuget-preview] | An Aspire integration leveraging the [MailPit](https://mailpit.axllent.org/) container. | | - **Learn More**: [`Hosting.k6`][k6-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.k6][k6-shields]][k6-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.k6][k6-shields-preview]][k6-nuget-preview] | An Aspire integration leveraging the [Grafana k6](https://k6.io/) container. | | - **Learn More**: [`Hosting.MySql.Extensions`][mysql-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.MySql.Extensions][mysql-ext-shields]][mysql-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.MySql.Extensions][mysql-ext-shields-preview]][mysql-ext-nuget-preview] | An integration that contains some additional extensions for hosting MySql container. | @@ -251,6 +252,11 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [lavinmq-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.LavinMQ/ [lavinmq-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.LavinMQ?label=nuget%20(preview) [lavinmq-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.LavinMQ/absoluteLatest +[redpanda-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-redpanda +[redpanda-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.RedPanda +[redpanda-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.RedPanda/ +[redpanda-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.RedPanda?label=nuget%20(preview) +[redpanda-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.RedPanda/absoluteLatest [mailpit-ext-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-mailpit [mailpit-ext-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.MailPit [mailpit-ext-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.MailPit/ diff --git a/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost.csproj b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost.csproj new file mode 100644 index 000000000..c13952dc1 --- /dev/null +++ b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost.csproj @@ -0,0 +1,15 @@ + + + + Exe + enable + enable + true + 3dc32fad-1d56-45b5-bea8-b96fa74a9628 + + + + + + + diff --git a/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/Program.cs b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/Program.cs new file mode 100644 index 000000000..1cfbd9156 --- /dev/null +++ b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/Program.cs @@ -0,0 +1,6 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddRedPanda("redpanda") + .WithConsole(); + +builder.Build().Run(); diff --git a/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/Properties/launchSettings.json b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..5f04271e0 --- /dev/null +++ b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17277;http://localhost:15247", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21270", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22001" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15247", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19100", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20013" + } + } + } +} diff --git a/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/appsettings.json b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/redpanda/CommunityToolkit.Aspire.Hosting.RedPanda.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/CommunityToolkit.Aspire.Hosting.RedPanda.csproj b/src/CommunityToolkit.Aspire.Hosting.RedPanda/CommunityToolkit.Aspire.Hosting.RedPanda.csproj new file mode 100644 index 000000000..b7dc4ef32 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/CommunityToolkit.Aspire.Hosting.RedPanda.csproj @@ -0,0 +1,16 @@ + + + + An Aspire hosting package for the Redpanda Kafka-compatible streaming platform. + hosting redpanda kafka streaming messaging + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/README.md b/src/CommunityToolkit.Aspire.Hosting.RedPanda/README.md new file mode 100644 index 000000000..2405b7f72 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/README.md @@ -0,0 +1,82 @@ +# CommunityToolkit.Aspire.Hosting.RedPanda + +## Overview + +This Aspire hosting integration runs [Redpanda](https://www.redpanda.com/) in a container. Redpanda is a Kafka API compatible streaming platform, so the resource can be referenced by any Kafka client integration (for example the official `Aspire.Confluent.Kafka` client integration). The integration exposes the Kafka API, the Schema Registry, and the Admin API, and can optionally run the Redpanda Console web UI. + +## Usage + +### Example 1: Add a Redpanda broker + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var redpanda = builder.AddRedPanda("redpanda"); + +builder.AddProject("myapp") + .WithReference(redpanda) + .WaitFor(redpanda); + +builder.Build().Run(); +``` + +The connection string injected into the referencing resource is the Kafka bootstrap server address in the form `host:port`. + +### Example 2: Pin the Kafka host port + +```csharp +var redpanda = builder.AddRedPanda("redpanda", port: 9092); +``` + +### Example 3: Tune the broker CPU and memory + +```csharp +var redpanda = builder.AddRedPanda("redpanda", options => +{ + options.CpuCount = 2; // Redpanda --smp, defaults to 1 + options.Memory = "2G"; // Redpanda --memory, defaults to "1G" +}); +``` + +### Example 4: Persist data with a volume or bind mount + +```csharp +var redpanda = builder.AddRedPanda("redpanda") + .WithDataVolume(); + +// or + +var redpanda = builder.AddRedPanda("redpanda") + .WithDataBindMount("./redpanda-data"); +``` + +### Example 5: Add the Redpanda Console web UI + +```csharp +var redpanda = builder.AddRedPanda("redpanda") + .WithConsole(console => console.WithHostPort(8080)); +``` + +The console is configured automatically to connect to the broker's Kafka API, Schema Registry, and Admin API. + +### Example 6: Add the Kafka UI web UI + +```csharp +var redpanda = builder.AddRedPanda("redpanda") + .WithKafkaUI(kafkaUi => kafkaUi.WithHostPort(9000)); +``` + +`WithKafkaUI` runs the same Kafka management UI (the `kafbat/kafka-ui` image) used by the official Aspire Kafka integration. It is wired automatically to the broker's Kafka API and Schema Registry, and can be used as an alternative to the Redpanda Console. + +## Endpoints + +| Name | Description | +| ---------------- | -------------------------------------------- | +| `kafka` | Kafka API (host accessible) | +| `internal` | Kafka API (container-to-container) | +| `schemaregistry` | Schema Registry HTTP API | +| `admin` | Admin API HTTP (used for the health check) | + +## Upstream Image + +This integration pins the `redpandadata/redpanda` image (from `docker.redpanda.com`) to a specific version tag (`v26.1.10`) rather than a floating tag, and the optional console uses `redpandadata/console` pinned to `v3.7.4`. Redpanda publishes immutable, fully-versioned tags (`vYY.M.P`); update the pinned tags to adopt newer releases. The optional Kafka UI uses `kafbat/kafka-ui` (from `docker.io`) pinned to `v1.5.0`. diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaBuilderExtensions.cs new file mode 100644 index 000000000..ccdb4ad93 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaBuilderExtensions.cs @@ -0,0 +1,309 @@ +using System.Globalization; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.RedPanda; + +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Redpanda resources to an . +/// +public static class RedPandaBuilderExtensions +{ + private const string DataTarget = "/var/lib/redpanda/data"; + + /// + /// Adds a Redpanda container resource to the application. Redpanda is a Kafka API compatible + /// streaming platform, so the resource can be referenced by any Kafka client integration. + /// + /// + /// This version of the package defaults to the tag of the container image. + /// + /// The to add the resource to. + /// The name of the resource. This name is used as the connection string name when referenced in a dependency. + /// The host port that the Kafka API is exposed on. If a random port is assigned. + /// A reference to the . + [AspireExport] + public static IResourceBuilder AddRedPanda( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + return builder.AddRedPandaCore(name, new RedPandaServerOptions(), port); + } + + /// + /// Adds a Redpanda container resource to the application, using a delegate to configure broker options + /// such as the CPU and memory limits. Redpanda is a Kafka API compatible streaming platform, so the + /// resource can be referenced by any Kafka client integration. + /// + /// + /// This version of the package defaults to the tag of the container image. + /// + /// The to add the resource to. + /// The name of the resource. This name is used as the connection string name when referenced in a dependency. + /// A delegate that configures the for the broker. + /// The host port that the Kafka API is exposed on. If a random port is assigned. + /// A reference to the . + [AspireExportIgnore(Reason = "Action is not ATS-compatible. Use the AddRedPanda(builder, name, port) overload instead.")] + public static IResourceBuilder AddRedPanda( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + Action configureOptions, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(configureOptions); + + RedPandaServerOptions options = new(); + configureOptions(options); + + return builder.AddRedPandaCore(name, options, port); + } + + private static IResourceBuilder AddRedPandaCore( + this IDistributedApplicationBuilder builder, + string name, + RedPandaServerOptions options, + int? port) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(options.CpuCount, $"{nameof(options)}.{nameof(options.CpuCount)}"); + ArgumentException.ThrowIfNullOrEmpty(options.Memory, $"{nameof(options)}.{nameof(options.Memory)}"); + + RedPandaServerResource resource = new(name); + + return builder.AddResource(resource) + .WithImage(RedPandaContainerImageTags.Image, RedPandaContainerImageTags.Tag) + .WithImageRegistry(RedPandaContainerImageTags.Registry) + .WithEndpoint(targetPort: RedPandaServerResource.KafkaBrokerPort, port: port, name: RedPandaServerResource.PrimaryEndpointName) + .WithEndpoint(targetPort: RedPandaServerResource.KafkaInternalBrokerPort, name: RedPandaServerResource.InternalEndpointName) + .WithHttpEndpoint(targetPort: RedPandaServerResource.SchemaRegistryPort, name: RedPandaServerResource.SchemaRegistryEndpointName) + .WithHttpEndpoint(targetPort: RedPandaServerResource.AdminPort, name: RedPandaServerResource.AdminEndpointName) + .WithEntrypoint("/usr/bin/rpk") + .WithArgs(context => ConfigureRedPandaArgs(context, resource, options)) + .WithHttpHealthCheck("/v1/status/ready", endpointName: RedPandaServerResource.AdminEndpointName); + } + + /// + /// Adds a named volume for the data folder to a Redpanda container resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// A flag that indicates if this is a read-only volume. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithDataVolume( + this IResourceBuilder builder, + string? name = null, + bool isReadOnly = false) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), DataTarget, isReadOnly); + } + + /// + /// Adds a bind mount for the data folder to a Redpanda container resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// A flag that indicates if this is a read-only mount. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithDataBindMount( + this IResourceBuilder builder, + string source, + bool isReadOnly = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(source); + + return builder.WithBindMount(source, DataTarget, isReadOnly); + } + + /// + /// Adds a Redpanda Console container to the application, configured to connect to the Redpanda broker. + /// + /// + /// This version of the package defaults to the tag of the container image. + /// + /// The Redpanda server resource builder. + /// An optional callback to configure the Redpanda Console container resource. + /// The name of the container (optional). + /// A reference to the for the Redpanda server resource. + [AspireExport(RunSyncOnBackgroundThread = true)] + public static IResourceBuilder WithConsole( + this IResourceBuilder builder, + Action>? configureContainer = null, + string? containerName = null) + { + ArgumentNullException.ThrowIfNull(builder); + + containerName ??= $"{builder.Resource.Name}-console"; + + RedPandaConsoleContainerResource console = new(containerName); + + IResourceBuilder consoleBuilder = builder.ApplicationBuilder.AddResource(console) + .WithImage(RedPandaContainerImageTags.ConsoleImage, RedPandaContainerImageTags.ConsoleTag) + .WithImageRegistry(RedPandaContainerImageTags.Registry) + .WithHttpEndpoint(targetPort: RedPandaConsoleContainerResource.HttpPort, name: RedPandaConsoleContainerResource.HttpEndpointName) + .WithEnvironment(context => ConfigureConsoleContainer(context, builder.Resource)) + .WaitFor(builder) + .ExcludeFromManifest(); + + configureContainer?.Invoke(consoleBuilder); + + return builder; + } + + /// + /// Configures the host port that the Redpanda Console resource is exposed on instead of using a randomly assigned port. + /// + /// The resource builder for the Redpanda Console. + /// The port to bind on the host. If a random port is assigned. + /// A reference to the . + [AspireExport] + public static IResourceBuilder WithHostPort( + this IResourceBuilder builder, + int? port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint(RedPandaConsoleContainerResource.HttpEndpointName, endpoint => endpoint.Port = port); + } + + /// + /// Adds a Kafka UI container to the application, configured to connect to the Redpanda broker. This is the + /// same Kafka management UI (the kafbat/kafka-ui image) used by the official Aspire Kafka integration, + /// so it works against any Kafka API compatible broker. + /// + /// + /// This version of the package defaults to the tag of the container image. + /// + /// The Redpanda server resource builder. + /// An optional callback to configure the Kafka UI container resource. + /// The name of the container (optional). + /// A reference to the for the Redpanda server resource. + [AspireExport(RunSyncOnBackgroundThread = true)] + public static IResourceBuilder WithKafkaUI( + this IResourceBuilder builder, + Action>? configureContainer = null, + string? containerName = null) + { + ArgumentNullException.ThrowIfNull(builder); + + containerName ??= $"{builder.Resource.Name}-kafka-ui"; + + RedPandaKafkaUiContainerResource kafkaUi = new(containerName); + + IResourceBuilder kafkaUiBuilder = builder.ApplicationBuilder.AddResource(kafkaUi) + .WithImage(RedPandaContainerImageTags.KafkaUiImage, RedPandaContainerImageTags.KafkaUiTag) + .WithImageRegistry(RedPandaContainerImageTags.KafkaUiRegistry) + .WithHttpEndpoint(targetPort: RedPandaKafkaUiContainerResource.HttpPort, name: RedPandaKafkaUiContainerResource.HttpEndpointName) + .WithEnvironment(context => ConfigureKafkaUiContainer(context, builder.Resource)) + .WaitFor(builder) + .ExcludeFromManifest(); + + configureContainer?.Invoke(kafkaUiBuilder); + + return builder; + } + + /// + /// Configures the host port that the Kafka UI resource is exposed on instead of using a randomly assigned port. + /// + /// The resource builder for the Kafka UI. + /// The port to bind on the host. If a random port is assigned. + /// A reference to the . + [AspireExportIgnore(Reason = "The exported 'withHostPort' capability is already provided by the RedPandaConsoleContainerResource overload; this overload remains available to C# callers.")] + public static IResourceBuilder WithHostPort( + this IResourceBuilder builder, + int? port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint(RedPandaKafkaUiContainerResource.HttpEndpointName, endpoint => endpoint.Port = port); + } + + private static void ConfigureRedPandaArgs(CommandLineArgsCallbackContext context, RedPandaServerResource resource, RedPandaServerOptions options) + { + // Start a single-node Redpanda broker tuned for local development. + // See https://docs.redpanda.com/current/reference/rpk/rpk-redpanda/rpk-redpanda-start/ + context.Args.Add("redpanda"); + context.Args.Add("start"); + context.Args.Add("--mode"); + context.Args.Add("dev-container"); + context.Args.Add("--smp"); + context.Args.Add(options.CpuCount.ToString(CultureInfo.InvariantCulture)); + context.Args.Add("--memory"); + context.Args.Add(options.Memory); + + // Two Kafka listeners: an "internal" listener for container-to-container traffic over the + // Aspire container network, and an "external" listener that is reachable from the host. + context.Args.Add("--kafka-addr"); + context.Args.Add($"internal://0.0.0.0:{RedPandaServerResource.KafkaInternalBrokerPort},external://0.0.0.0:{RedPandaServerResource.KafkaBrokerPort}"); + + // Advertised listeners tell clients how to reach the broker after the initial connection. + var internalPort = RedPandaServerResource.KafkaInternalBrokerPort.ToString(CultureInfo.InvariantCulture); + var advertised = context.ExecutionContext.IsRunMode + // In run mode, the internal listener is reached over the default Aspire container network using + // the resource name, and the external listener is reached from the host on the mapped port. + ? ReferenceExpression.Create( + $"internal://{resource.Name}:{internalPort},external://localhost:{resource.PrimaryEndpoint.Property(EndpointProperty.Port)}") + : ReferenceExpression.Create( + $"internal://{resource.InternalEndpoint.Property(EndpointProperty.HostAndPort)},external://{resource.PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + context.Args.Add("--advertise-kafka-addr"); + context.Args.Add(advertised); + + context.Args.Add("--schema-registry-addr"); + context.Args.Add($"0.0.0.0:{RedPandaServerResource.SchemaRegistryPort}"); + } + + private static void ConfigureConsoleContainer(EnvironmentCallbackContext context, RedPandaServerResource resource) + { + // The console runs in its own container, so it reaches Redpanda over the default Aspire container + // network in run mode (using the resource name + target ports) and over the host otherwise. + var brokers = context.ExecutionContext.IsRunMode + ? ReferenceExpression.Create($"{resource.Name}:{resource.InternalEndpoint.Property(EndpointProperty.TargetPort)}") + : ReferenceExpression.Create($"{resource.InternalEndpoint.Property(EndpointProperty.HostAndPort)}"); + + var schemaRegistry = context.ExecutionContext.IsRunMode + ? ReferenceExpression.Create($"http://{resource.Name}:{resource.SchemaRegistryEndpoint.Property(EndpointProperty.TargetPort)}") + : ReferenceExpression.Create($"{resource.SchemaRegistryEndpoint.Property(EndpointProperty.Scheme)}://{resource.SchemaRegistryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + var adminApi = context.ExecutionContext.IsRunMode + ? ReferenceExpression.Create($"http://{resource.Name}:{resource.AdminEndpoint.Property(EndpointProperty.TargetPort)}") + : ReferenceExpression.Create($"{resource.AdminEndpoint.Property(EndpointProperty.Scheme)}://{resource.AdminEndpoint.Property(EndpointProperty.HostAndPort)}"); + + context.EnvironmentVariables["KAFKA_BROKERS"] = brokers; + context.EnvironmentVariables["KAFKA_SCHEMAREGISTRY_ENABLED"] = "true"; + context.EnvironmentVariables["KAFKA_SCHEMAREGISTRY_URLS"] = schemaRegistry; + context.EnvironmentVariables["REDPANDA_ADMINAPI_ENABLED"] = "true"; + context.EnvironmentVariables["REDPANDA_ADMINAPI_URLS"] = adminApi; + } + + private static void ConfigureKafkaUiContainer(EnvironmentCallbackContext context, RedPandaServerResource resource) + { + // Kafka UI runs in its own container, so it reaches Redpanda over the default Aspire container + // network in run mode (using the resource name + target ports) and over the host otherwise. + var bootstrapServers = context.ExecutionContext.IsRunMode + ? ReferenceExpression.Create($"{resource.Name}:{resource.InternalEndpoint.Property(EndpointProperty.TargetPort)}") + : ReferenceExpression.Create($"{resource.InternalEndpoint.Property(EndpointProperty.HostAndPort)}"); + + var schemaRegistry = context.ExecutionContext.IsRunMode + ? ReferenceExpression.Create($"http://{resource.Name}:{resource.SchemaRegistryEndpoint.Property(EndpointProperty.TargetPort)}") + : ReferenceExpression.Create($"{resource.SchemaRegistryEndpoint.Property(EndpointProperty.Scheme)}://{resource.SchemaRegistryEndpoint.Property(EndpointProperty.HostAndPort)}"); + + context.EnvironmentVariables["KAFKA_CLUSTERS_0_NAME"] = resource.Name; + context.EnvironmentVariables["KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS"] = bootstrapServers; + context.EnvironmentVariables["KAFKA_CLUSTERS_0_SCHEMAREGISTRY"] = schemaRegistry; + } +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaConsoleContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaConsoleContainerResource.cs new file mode 100644 index 000000000..f7f838398 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaConsoleContainerResource.cs @@ -0,0 +1,17 @@ +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Redpanda Console container, a web UI for managing and inspecting +/// one or more Redpanda (or Kafka) brokers. +/// +/// The name of the resource. +[AspireExport(ExposeProperties = true)] +public class RedPandaConsoleContainerResource(string name) : ContainerResource(name) +{ + internal const string HttpEndpointName = "http"; + internal const int HttpPort = 8080; +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaContainerImageTags.cs new file mode 100644 index 000000000..65c141b74 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaContainerImageTags.cs @@ -0,0 +1,28 @@ +namespace CommunityToolkit.Aspire.Hosting.RedPanda; + +internal static class RedPandaContainerImageTags +{ + /// docker.redpanda.com + internal const string Registry = "docker.redpanda.com"; + + /// redpandadata/redpanda + internal const string Image = "redpandadata/redpanda"; + + /// v26.1.10 + internal const string Tag = "v26.1.10"; + + /// redpandadata/console + internal const string ConsoleImage = "redpandadata/console"; + + /// v3.7.4 + internal const string ConsoleTag = "v3.7.4"; + + /// docker.io + internal const string KafkaUiRegistry = "docker.io"; + + /// kafbat/kafka-ui + internal const string KafkaUiImage = "kafbat/kafka-ui"; + + /// v1.5.0 + internal const string KafkaUiTag = "v1.5.0"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaKafkaUiContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaKafkaUiContainerResource.cs new file mode 100644 index 000000000..ef1759694 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaKafkaUiContainerResource.cs @@ -0,0 +1,17 @@ +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Kafka UI container (the kafbat/kafka-ui image), a web UI for managing +/// and inspecting one or more Kafka API compatible brokers such as Redpanda. +/// +/// The name of the resource. +[AspireExport(ExposeProperties = true)] +public class RedPandaKafkaUiContainerResource(string name) : ContainerResource(name) +{ + internal const string HttpEndpointName = "http"; + internal const int HttpPort = 8080; +} + +#pragma warning restore ASPIREATS001 diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaServerOptions.cs b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaServerOptions.cs new file mode 100644 index 000000000..4daed57ed --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaServerOptions.cs @@ -0,0 +1,21 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Options for configuring the broker of a . +/// +public class RedPandaServerOptions +{ + /// + /// Gets or sets the number of logical CPU cores the broker is allowed to use + /// (the Redpanda --smp flag). Defaults to 1. + /// + public int CpuCount { get; set; } = 1; + + /// + /// Gets or sets the amount of memory the broker is allowed to use + /// (the Redpanda --memory flag), for example "1G". Defaults to "1G". + /// + public string Memory { get; set; } = "1G"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaServerResource.cs b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaServerResource.cs new file mode 100644 index 000000000..f6362cb55 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RedPanda/RedPandaServerResource.cs @@ -0,0 +1,75 @@ +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Redpanda streaming platform container. +/// +/// +/// Redpanda is a Kafka API compatible streaming platform. The +/// returns the bootstrap broker address (host:port) that Kafka clients use to connect. +/// +/// The name of the resource. +[AspireExport(ExposeProperties = true)] +public class RedPandaServerResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "kafka"; + internal const string InternalEndpointName = "internal"; + internal const string SchemaRegistryEndpointName = "schemaregistry"; + internal const string AdminEndpointName = "admin"; + + internal const int KafkaBrokerPort = 9092; + internal const int KafkaInternalBrokerPort = 29092; + internal const int SchemaRegistryPort = 8081; + internal const int AdminPort = 9644; + + private EndpointReference? _primaryEndpoint; + private EndpointReference? _internalEndpoint; + private EndpointReference? _schemaRegistryEndpoint; + private EndpointReference? _adminEndpoint; + + /// + /// Gets the primary (Kafka API) endpoint for the Redpanda broker. This endpoint is reachable from the host. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the internal Kafka API endpoint used for container-to-container communication. + /// + public EndpointReference InternalEndpoint => _internalEndpoint ??= new(this, InternalEndpointName); + + /// + /// Gets the Schema Registry HTTP endpoint for the Redpanda broker. + /// + public EndpointReference SchemaRegistryEndpoint => _schemaRegistryEndpoint ??= new(this, SchemaRegistryEndpointName); + + /// + /// Gets the Admin API HTTP endpoint for the Redpanda broker. + /// + public EndpointReference AdminEndpoint => _adminEndpoint ??= new(this, AdminEndpointName); + + /// + /// Gets the host of the primary Kafka endpoint. + /// + public EndpointReferenceExpression Host => PrimaryEndpoint.Property(EndpointProperty.Host); + + /// + /// Gets the port of the primary Kafka endpoint. + /// + public EndpointReferenceExpression Port => PrimaryEndpoint.Property(EndpointProperty.Port); + + /// + /// Gets the connection string expression for the Redpanda broker in the form of host:port, + /// suitable for use as the Kafka bootstrap servers value. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"{Host}:{Port}"); + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Host", ReferenceExpression.Create($"{Host}")); + yield return new("Port", ReferenceExpression.Create($"{Port}")); + } +} + +#pragma warning restore ASPIREATS001 diff --git a/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/AppHostTests.cs new file mode 100644 index 000000000..917385d07 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/AppHostTests.cs @@ -0,0 +1,35 @@ +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.RedPanda.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) + : IClassFixture> +{ + private const string ResourceName = "redpanda"; + + [Fact] + public async Task ResourceStartsAndAdminApiReportsReady() + { + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(ResourceName).WaitAsync(TimeSpan.FromMinutes(2)); + + HttpClient client = fixture.CreateHttpClient(ResourceName, "admin"); + + HttpResponseMessage response = await client.GetAsync("/v1/status/ready"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task SchemaRegistryRespondsOk() + { + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(ResourceName).WaitAsync(TimeSpan.FromMinutes(2)); + + HttpClient client = fixture.CreateHttpClient(ResourceName, "schemaregistry"); + + HttpResponseMessage response = await client.GetAsync("/subjects"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests.csproj new file mode 100644 index 000000000..53db91bcc --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests.csproj @@ -0,0 +1,14 @@ + + + + false + true + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/ResourceCreationTests.cs new file mode 100644 index 000000000..51b392414 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RedPanda.Tests/ResourceCreationTests.cs @@ -0,0 +1,253 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.RedPanda.Tests; + +public class ResourceCreationTests +{ + [Fact] + public void AddRedPandaShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + Assert.Throws(() => builder.AddRedPanda("redpanda")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void AddRedPandaShouldThrowWhenNameIsNullOrEmpty(string? name) + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + Assert.ThrowsAny(() => builder.AddRedPanda(name!)); + } + + [Fact] + public void AddRedPandaSetsContainerImageAnnotations() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda"); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("redpanda", resource.Name); + + Assert.True(resource.TryGetLastAnnotation(out ContainerImageAnnotation? image)); + Assert.Equal("redpandadata/redpanda", image.Image); + Assert.Equal("v26.1.10", image.Tag); + Assert.Equal("docker.redpanda.com", image.Registry); + } + + [Fact] + public void AddRedPandaCreatesExpectedEndpoints() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda"); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out IEnumerable? annotations)); + Dictionary endpoints = annotations!.ToDictionary(e => e.Name); + + Assert.Equal(4, endpoints.Count); + Assert.Equal(RedPandaServerResource.KafkaBrokerPort, endpoints["kafka"].TargetPort); + Assert.Equal(RedPandaServerResource.KafkaInternalBrokerPort, endpoints["internal"].TargetPort); + Assert.Equal(RedPandaServerResource.SchemaRegistryPort, endpoints["schemaregistry"].TargetPort); + Assert.Equal(RedPandaServerResource.AdminPort, endpoints["admin"].TargetPort); + Assert.Equal("http", endpoints["schemaregistry"].UriScheme); + Assert.Equal("http", endpoints["admin"].UriScheme); + } + + [Fact] + public void AddRedPandaUsesProvidedHostPortForKafkaEndpoint() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda", port: 9092); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + EndpointAnnotation kafka = resource.GetEndpoint("kafka").EndpointAnnotation; + Assert.Equal(9092, kafka.Port); + } + + [Fact] + public void ConnectionStringExpressionUsesKafkaEndpoint() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + IResourceBuilder redpanda = builder.AddRedPanda("redpanda"); + + Assert.Equal( + "{redpanda.bindings.kafka.host}:{redpanda.bindings.kafka.port}", + redpanda.Resource.ConnectionStringExpression.ValueExpression); + } + + [Fact] + public void WithConsoleAddsConsoleResource() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda").WithConsole(); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaConsoleContainerResource console = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("redpanda-console", console.Name); + + Assert.True(console.TryGetLastAnnotation(out ContainerImageAnnotation? image)); + Assert.Equal("redpandadata/console", image.Image); + Assert.Equal("v3.7.4", image.Tag); + } + + [Fact] + public void WithKafkaUIAddsKafkaUiResource() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda").WithKafkaUI(); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaKafkaUiContainerResource kafkaUi = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("redpanda-kafka-ui", kafkaUi.Name); + + Assert.True(kafkaUi.TryGetLastAnnotation(out ContainerImageAnnotation? image)); + Assert.Equal("kafbat/kafka-ui", image.Image); + Assert.Equal("v1.5.0", image.Tag); + Assert.Equal("docker.io", image.Registry); + } + + [Fact] + public void WithKafkaUIHostPortSetsEndpointPort() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda").WithKafkaUI(kafkaUi => kafkaUi.WithHostPort(9000)); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaKafkaUiContainerResource kafkaUi = Assert.Single(appModel.Resources.OfType()); + EndpointAnnotation http = kafkaUi.GetEndpoint("http").EndpointAnnotation; + Assert.Equal(9000, http.Port); + } + + [Fact] + public void WithConsoleHostPortSetsEndpointPort() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda").WithConsole(console => console.WithHostPort(9090)); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaConsoleContainerResource console = Assert.Single(appModel.Resources.OfType()); + EndpointAnnotation http = console.GetEndpoint("http").EndpointAnnotation; + Assert.Equal(9090, http.Port); + } + + [Fact] + public void WithDataVolumeAddsVolumeAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda").WithDataVolume("redpanda-data"); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out IEnumerable? mounts)); + ContainerMountAnnotation mount = Assert.Single(mounts!); + Assert.Equal("redpanda-data", mount.Source); + Assert.Equal("/var/lib/redpanda/data", mount.Target); + Assert.Equal(ContainerMountType.Volume, mount.Type); + } + + [Fact] + public void WithDataBindMountAddsBindMountAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda").WithDataBindMount("./redpanda-data"); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource.TryGetAnnotationsOfType(out IEnumerable? mounts)); + ContainerMountAnnotation mount = Assert.Single(mounts!); + Assert.EndsWith("redpanda-data", mount.Source); + Assert.Equal("/var/lib/redpanda/data", mount.Target); + Assert.Equal(ContainerMountType.BindMount, mount.Type); + Assert.False(mount.IsReadOnly); + } + + [Fact] + public async Task AddRedPandaUsesDefaultCpuAndMemoryArgs() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda"); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + + IList args = await GetRedPandaArgsAsync(resource); + + AssertArgValue(args, "--smp", "1"); + AssertArgValue(args, "--memory", "1G"); + } + + [Fact] + public async Task AddRedPandaWithOptionsConfiguresCpuAndMemoryArgs() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddRedPanda("redpanda", options => + { + options.CpuCount = 4; + options.Memory = "2G"; + }); + + using DistributedApplication app = builder.Build(); + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + RedPandaServerResource resource = Assert.Single(appModel.Resources.OfType()); + + IList args = await GetRedPandaArgsAsync(resource); + + AssertArgValue(args, "--smp", "4"); + AssertArgValue(args, "--memory", "2G"); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void AddRedPandaThrowsWhenCpuCountIsNotPositive(int cpuCount) + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + Assert.Throws(() => builder.AddRedPanda("redpanda", options => options.CpuCount = cpuCount)); + } + + private static async Task> GetRedPandaArgsAsync(RedPandaServerResource resource) + { + CommandLineArgsCallbackAnnotation annotation = Assert.Single(resource.Annotations.OfType()); + CommandLineArgsCallbackContext context = new([], resource, CancellationToken.None) + { + ExecutionContext = new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run) + }; + await annotation.Callback(context); + return context.Args; + } + + private static void AssertArgValue(IList args, string flag, string expectedValue) + { + int index = args.IndexOf(flag); + Assert.True(index >= 0 && index + 1 < args.Count, $"Expected argument '{flag}' to be present with a value."); + Assert.Equal(expectedValue, args[index + 1]); + } +}