diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e178e4885..4247e0d66 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -29,6 +29,7 @@ jobs: Hosting.DbGate.Tests, Hosting.Deno.Tests, Hosting.EventStore.Tests, + Hosting.Flagd.Tests, Hosting.GoFeatureFlag.Tests, Hosting.Golang.Tests, Hosting.Java.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index b27af11f5..ba2cd5434 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -35,6 +35,9 @@ + + + @@ -166,6 +169,7 @@ + @@ -217,6 +221,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 9222db74c..98fea59f7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -96,6 +96,8 @@ + + diff --git a/README.md b/README.md index 42271afeb..51e0b49e0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This repository contains the source code for the .NET Aspire Community Toolkit, | - **Learn More**: [`Hosting.Python.Extensions`][python-ext-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.Python.Extensions][python-ext-shields]][python-ext-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.Hosting.Python.Extensions][python-ext-shields-preview]][python-ext-nuget-preview] | An integration that contains some additional extensions for running python applications | | - **Learn More**: [`Hosting.EventStore`][eventstore-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.Hosting.EventStore][eventstore-shields]][eventstore-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.Hosting.EventStore][eventstore-shields-preview]][eventstore-nuget-preview] | An Aspire hosting integration leveraging the [EventStore](https://eventstore.com) container. | | - **Learn More**: [`EventStore`][eventstore-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.EventStore][eventstore-client-shields]][eventstore-client-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.EventStore][eventstore-client-shields-preview]][eventstore-client-nuget-preview] | An Aspire client integration for the [EventStore](https://github.com/EventStore/EventStore-Client-Dotnet) package. | +| - **Learn More**: [`Hosting.Flagd`][flagd-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.Hosting.Flagd][flagd-shields]][flagd-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.Hosting.Flagd][flagd-shields-preview]][flagd-nuget-preview] | A .NET Aspire hosting integration for [flagd](https://flagd.dev), a feature flag evaluation engine. | | - **Learn More**: [`Hosting.ActiveMQ`][activemq-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.Hosting.ActiveMQ][activemq-shields]][activemq-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.Hosting.ActiveMQ][activemq-shields-preview]][activemq-nuget-preview] | An Aspire hosting integration leveraging the [ActiveMq](https://activemq.apache.org) container. | | - **Learn More**: [`Hosting.Sqlite`][sqlite-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.Hosting.Sqlite][sqlite-shields]][sqlite-hosting-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.Hosting.Sqlite][sqlite-shields-preview]][sqlite-hosting-nuget-preview] | An Aspire hosting integration to setup a SQLite database with optional SQLite Web as a dev UI. | | - **Learn More**: [`Microsoft.Data.Sqlite`][sqlite-integration-docs]
- Stable πŸ“¦: [![CommunityToolkit.Aspire.Microsoft.Data.Sqlite][sqlite-shields]][sqlite-nuget]
- Preview πŸ“¦: [![CommunityToolkit.Aspire.Microsoft.Data.Sqlite][sqlite-shields-preview]][sqlite-nuget-preview] | An Aspire client integration for the Microsoft.Data.Sqlite NuGet package. | @@ -163,6 +164,11 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [eventstore-client-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.EventStore/ [eventstore-client-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.EventStore?label=nuget%20(preview) [eventstore-client-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.EventStore/absoluteLatest +[flagd-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-flagd +[flagd-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.Flagd +[flagd-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Flagd/ +[flagd-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.Flagd?label=nuget%20(preview) +[flagd-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.Flagd/absoluteLatest [activemq-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-activemq [activemq-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.ActiveMQ [activemq-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.ActiveMQ/ @@ -268,3 +274,4 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [surrealdb-client-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.SurrealDb/ [surrealdb-client-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.SurrealDb?label=nuget%20(preview) [surrealdb-client-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.SurrealDb/absoluteLatest + diff --git a/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/CommunityToolkit.Aspire.Hosting.Flagd.AppHost.csproj b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/CommunityToolkit.Aspire.Hosting.Flagd.AppHost.csproj new file mode 100644 index 000000000..81b0b3c88 --- /dev/null +++ b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/CommunityToolkit.Aspire.Hosting.Flagd.AppHost.csproj @@ -0,0 +1,22 @@ + + + + + + Exe + enable + enable + true + 6445164b-1ed4-461b-baa5-86790b0c158d + + + + + + + + + + + + diff --git a/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/Program.cs b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/Program.cs new file mode 100644 index 000000000..0f11e3653 --- /dev/null +++ b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/Program.cs @@ -0,0 +1,11 @@ +using OpenFeature.Contrib.Providers.Flagd; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add flagd with local flag configuration file +var flagd = builder + .AddFlagd("flagd") + .WithBindFileSync("./flags/") + .WithLogLevel(Microsoft.Extensions.Logging.LogLevel.Debug); + +builder.Build().Run(); diff --git a/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/Properties/launchSettings.json b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..bb3e0bd1a --- /dev/null +++ b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.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:17255;http://localhost:15238", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21210", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22287" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15238", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19230", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20107" + } + } + } +} diff --git a/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/appsettings.json b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/flags/flagd.json b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/flags/flagd.json new file mode 100644 index 000000000..380828fd9 --- /dev/null +++ b/examples/flagd/CommunityToolkit.Aspire.Hosting.Flagd.AppHost/flags/flagd.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "welcome-banner": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "background-color": { + "state": "ENABLED", + "variants": { + "red": "#FF0000", + "blue": "#0000FF", + "green": "#00FF00", + "yellow": "#FFFF00" + }, + "defaultVariant": "red", + "targeting": { + "if": [ + { + "===": [ + { + "var": "company" + }, + "aspire" + ] + }, + "blue" + ] + } + }, + "api-version": { + "state": "ENABLED", + "variants": { + "v1": "1.0", + "v2": "2.0", + "v3": "3.0" + }, + "defaultVariant": "v1" + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Flagd/CommunityToolkit.Aspire.Hosting.Flagd.csproj b/src/CommunityToolkit.Aspire.Hosting.Flagd/CommunityToolkit.Aspire.Hosting.Flagd.csproj new file mode 100644 index 000000000..ca9184e38 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Flagd/CommunityToolkit.Aspire.Hosting.Flagd.csproj @@ -0,0 +1,16 @@ + + + + hosting flagd feature-flags openfeature + flagd is a feature flag evaluation engine. Think of it as a ready-made, open source, OpenFeature-compliant feature flag backend system. + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdBuilderExtensions.cs new file mode 100644 index 000000000..30c7b58ea --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdBuilderExtensions.cs @@ -0,0 +1,85 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.Flagd; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding flagd resources to an . +/// +public static class FlagdBuilderExtensions +{ + private const int FlagdPort = 8013; + private const int HealthCheckPort = 8014; + private const int OfrepEndpoint = 8016; + + /// + /// Adds a flagd container to the application model. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port for flagd HTTP endpoint. If not provided, a random port will be assigned. + /// The host port for flagd OFREP endpoint. If not provided, a random port will be assigned. + /// The flagd container requires a sync source to be configured. + /// A reference to the . + public static IResourceBuilder AddFlagd( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + int? port = null, + int? ofrepPort = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrEmpty(name, nameof(name)); + + var resource = new FlagdResource(name); + + return builder.AddResource(resource) + .WithImage(FlagdContainerImageTags.Image, FlagdContainerImageTags.Tag) + .WithImageRegistry(FlagdContainerImageTags.Registry) + .WithHttpEndpoint(port: port, targetPort: FlagdPort, name: FlagdResource.HttpEndpointName) + .WithHttpEndpoint(null, HealthCheckPort, FlagdResource.HealthCheckEndpointName) + .WithHttpHealthCheck("/healthz", endpointName: FlagdResource.HealthCheckEndpointName) + .WithHttpEndpoint(ofrepPort, OfrepEndpoint, FlagdResource.OfrepEndpointName) + .WithArgs("start"); + } + + /// + /// Configures logging level for flagd. If a flag or targeting rule isn't proceeding the way you'd expect this can be enabled to get more verbose logging. + /// + /// The resource builder. + /// The log level to use. Currently only debug is supported. + /// The . + /// Thrown if the log level is not valid. + /// Currently only debug is supported. + public static IResourceBuilder WithLogLevel( + this IResourceBuilder builder, + LogLevel logLevel) + { + if (logLevel == LogLevel.Debug) + { + return builder.WithEnvironment("FLAGD_DEBUG", "true"); + } + + throw new InvalidOperationException("Only debug log level is supported"); + } + + /// + /// Configures flagd to use a bind mount as the source of flags. + /// + /// The resource builder. + /// The path to the flag configuration file on the host. + /// The name of the flag configuration file. Defaults to "flagd.json". + /// The . + public static IResourceBuilder WithBindFileSync( + this IResourceBuilder builder, + string fileSource, + string filename = "flagd.json") + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentException.ThrowIfNullOrEmpty(fileSource, nameof(fileSource)); + + return builder + .WithBindMount(fileSource, "/flags/") + .WithArgs("--uri", $"file:./flags/{filename}"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdContainerImageTags.cs new file mode 100644 index 000000000..07452bd30 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdContainerImageTags.cs @@ -0,0 +1,11 @@ +namespace CommunityToolkit.Aspire.Hosting.Flagd; + +internal static class FlagdContainerImageTags +{ + /// ghcr.io + public const string Registry = "ghcr.io"; + /// open-feature/flagd + public const string Image = "open-feature/flagd"; + /// v0.12.9 + public const string Tag = "v0.12.9"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdResource.cs b/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdResource.cs new file mode 100644 index 000000000..1fcf514ba --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Flagd/FlagdResource.cs @@ -0,0 +1,44 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a flagd container. +/// +/// +/// Constructs a . +/// +/// The name of the resource. +public class FlagdResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const string HttpEndpointName = "http"; + internal const string HealthCheckEndpointName = "health"; + internal const string OfrepEndpointName = "ofrep"; + + private EndpointReference? _primaryEndpointReference; + + private EndpointReference? _healthCheckEndpointReference; + + private EndpointReference? _ofrepEndpointReference; + + /// + /// Gets the primary HTTP endpoint for the flagd server. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpointReference ??= new(this, HttpEndpointName); + + /// + /// Gets the health check HTTP endpoint for the flagd server. + /// + public EndpointReference HealthCheckEndpoint => _healthCheckEndpointReference ??= new(this, HealthCheckEndpointName); + + /// + /// Gets the connection string for the flagd server. + /// + public EndpointReference OfrepEndpoint => _ofrepEndpointReference ??= new(this, OfrepEndpointName); + + /// + /// Gets the connection string expression for the flagd server. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}" + ); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Flagd/README.md b/src/CommunityToolkit.Aspire.Hosting.Flagd/README.md new file mode 100644 index 000000000..50aeab4df --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Flagd/README.md @@ -0,0 +1,132 @@ +# CommunityToolkit.Aspire.Hosting.Flagd + +A .NET Aspire hosting integration for [flagd](https://flagd.dev), a feature flag evaluation engine that provides a ready-made, open source, OpenFeature-compliant feature flag backend system. + +## Getting started + +### Prerequisites + +- .NET 8.0 or later +- Docker (for running the flagd container) + +### Installation + +Install the package by adding a PackageReference to your `AppHost` project: + +```xml + +``` + +### Usage + +In your `AppHost` project, call the `AddFlagd` method to add flagd to your application with a flag configuration file: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var flagd = builder + .AddFlagd("flagd") + .WithBindFileSync("./flags/") + .WithLogging(); + +builder.Build().Run(); +``` + +The `fileSource` parameter specifies the path to your flag configuration file on the host machine, which will be mounted into the flagd container. + +Important: The `flagd` requires a Sync to be configured. You can use the `WithBindFileSync` method to configure a file sync. The `./flags/` path is the default path where the flag configuration file is expected to be found. You can change this path to match your configuration. + +### Configuration + +#### Configuring logging + +You can enable the logging for flagd: + +```csharp +var flagd = builder + .AddFlagd("flagd") + .WithBindFileSync("./flags") + .WithLogging(); +``` + +#### Customizing the port (flagd endpoint) + +You can specify a custom port for the flagd HTTP endpoints: + +```csharp +var flagd = builder.AddFlagd("flagd", port: 9090); +``` + +If no port is specified, the default port 8013 will be used. + +#### Customizing the port (OFREP endpoint) + +You can specify a custom port for the OFREP HTTP endpoints: + +```csharp +var flagd = builder.AddFlagd("flagd", ofrepPort: 9090); +``` + +If no port is specified, the default port 8016 will be used. + +### Flag Configuration Format + +flagd uses JSON files for flag definitions. Please refer to the official documentation for more information. You can create +a folder named `flags` in your project root and place your `flagd.json` file inside it. It is mandatory for the flag configuration +file to be called `flagd.json`. + +Here's a simple example: + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "welcome-banner": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "off" + }, + "background-color": { + "state": "ENABLED", + "variants": { + "red": "#FF0000", + "blue": "#0000FF", + "yellow": "#FFFF00" + }, + "defaultVariant": "red", + "targeting": { + "if": [ + { + "===": [ + { + "var": "company" + }, + "aspire" + ] + }, + "blue" + ] + } + }, + "api-version": { + "state": "ENABLED", + "variants": { + "v1": "1.0", + "v2": "2.0", + "v3": "3.0" + }, + "defaultVariant": "v1" + } + } +} +``` + +## Additional Information + +For more information about flagd, visit the [official documentation](https://flagd.dev). + +To use flagd in your application, you'll need to install an OpenFeature provider for .NET. See the [OpenFeature .NET documentation](https://openfeature.dev/docs/reference/technologies/client/dotnet/) for details. + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/AddFlagdTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/AddFlagdTests.cs new file mode 100644 index 000000000..7a78df912 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/AddFlagdTests.cs @@ -0,0 +1,287 @@ +ο»Ώusing Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CommunityToolkit.Aspire.Hosting.Flagd.Tests; + +public class AddFlagdTests +{ + private const string FlagdName = "flagd"; + private const string FlagdSource = "./flags/flags.json"; + + [Fact] + public void AddFlagdCreatesCorrectResource() + { + var builder = DistributedApplication.CreateBuilder(); + var flagd = builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal(FlagdName, resource.Name); + } + + [Fact] + public void AddFlagdWithCustomPortSetsCorrectPort() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName, port: 12345); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var endpoint = resource.Annotations.OfType() + .First(e => e.Name == FlagdResource.HttpEndpointName); + + Assert.Equal(12345, endpoint.Port); + Assert.Equal(8013, endpoint.TargetPort); + } + + [Fact] + public void AddFlagdWithCustomOfrepPortSetsCorrectPort() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName, ofrepPort: 54321); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var endpoint = resource.Annotations.OfType() + .First(e => e.Name == FlagdResource.OfrepEndpointName); + + Assert.Equal(54321, endpoint.Port); + Assert.Equal(8016, endpoint.TargetPort); + } + + [Fact] + public void AddFlagdSetsCorrectContainerImage() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var containerAnnotation = Assert.Single(resource.Annotations.OfType()); + + Assert.Equal("ghcr.io", containerAnnotation.Registry); + Assert.Equal("open-feature/flagd", containerAnnotation.Image); + Assert.Equal("v0.12.9", containerAnnotation.Tag); + } + + [Fact] + public void AddFlagdSetsCorrectEndpoints() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var endpoints = resource.Annotations.OfType().ToArray(); + + Assert.Equal(3, endpoints.Length); + + var httpEndpoint = endpoints.First(e => e.Name == FlagdResource.HttpEndpointName); + Assert.Equal(8013, httpEndpoint.TargetPort); + Assert.Null(httpEndpoint.Port); + + var healthEndpoint = endpoints.First(e => e.Name == FlagdResource.HealthCheckEndpointName); + Assert.Equal(8014, healthEndpoint.TargetPort); + Assert.Null(healthEndpoint.Port); + + var ofrepEndpoint = endpoints.First(e => e.Name == FlagdResource.OfrepEndpointName); + Assert.Equal(8016, ofrepEndpoint.TargetPort); + Assert.Null(ofrepEndpoint.Port); + } + + [Fact] + public void AddFlagdAddsHealthCheck() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var healthCheckAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.NotNull(healthCheckAnnotation); + } + + [Fact] + public void AddFlagdAddsStartArgument() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var commandLineArgs = resource.Annotations.OfType().ToArray(); + Assert.NotEmpty(commandLineArgs); + } + + [Fact] + public void WithLoglevelDebugAddsEnvironmentVariable() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName).WithLogLevel(Microsoft.Extensions.Logging.LogLevel.Debug); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var envAnnotations = resource.Annotations.OfType().ToArray(); + Assert.NotEmpty(envAnnotations); + } + + [Theory] + [InlineData(Microsoft.Extensions.Logging.LogLevel.Trace)] + [InlineData(Microsoft.Extensions.Logging.LogLevel.Information)] + [InlineData(Microsoft.Extensions.Logging.LogLevel.Warning)] + [InlineData(Microsoft.Extensions.Logging.LogLevel.Error)] + [InlineData(Microsoft.Extensions.Logging.LogLevel.Critical)] + [InlineData(Microsoft.Extensions.Logging.LogLevel.None)] + public void WithLoglevelThrowsForUnsupportedLogLevels(Microsoft.Extensions.Logging.LogLevel logLevel) + { + var builder = DistributedApplication.CreateBuilder(); + var flagd = builder.AddFlagd(FlagdName); + + var exception = Assert.Throws(() => flagd.WithLogLevel(logLevel)); + Assert.Equal("Only debug log level is supported", exception.Message); + } + + [Fact] + public void WithLoglevelThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => builder.WithLogLevel(Microsoft.Extensions.Logging.LogLevel.Debug)); + } + + [Fact] + public void WithBindFileSyncAddsBindMountAndArgs() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName).WithBindFileSync(FlagdSource); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var bindMountAnnotation = resource.Annotations.OfType() + .FirstOrDefault(m => m.Target == "/flags/"); + Assert.NotNull(bindMountAnnotation); + // The source path may be converted to an absolute path, just check it's not null/empty + Assert.NotNull(bindMountAnnotation.Source); + Assert.NotEmpty(bindMountAnnotation.Source); + + var commandLineArgs = resource.Annotations.OfType().ToArray(); + Assert.NotEmpty(commandLineArgs); + } + + [Fact] + public void WithBindFileSyncWithCustomFilenameAddsCorrectArgs() + { + var builder = DistributedApplication.CreateBuilder(); + var customFilename = "custom-flags.json"; + builder.AddFlagd(FlagdName).WithBindFileSync(FlagdSource, customFilename); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var bindMountAnnotation = resource.Annotations.OfType() + .FirstOrDefault(m => m.Target == "/flags/"); + Assert.NotNull(bindMountAnnotation); + } + + [Fact] + public void FlagdResourceImplementsIResourceWithConnectionString() + { + var builder = DistributedApplication.CreateBuilder(); + var flagd = builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(resource is IResourceWithConnectionString); + + var connectionStringResource = resource as IResourceWithConnectionString; + Assert.NotNull(connectionStringResource?.ConnectionStringExpression); + } + + [Fact] + public void FlagdResourceExposesEndpointReferences() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddFlagd(FlagdName); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.NotNull(resource.PrimaryEndpoint); + Assert.NotNull(resource.HealthCheckEndpoint); + Assert.NotNull(resource.OfrepEndpoint); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddFlagdThrowsWhenNameIsNullOrEmpty(string? name) + { + var builder = DistributedApplication.CreateBuilder(); + + Assert.ThrowsAny(() => builder.AddFlagd(name!)); + } + + [Fact] + public void AddFlagdThrowsWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + + Assert.Throws(() => builder.AddFlagd(FlagdName)); + } + + [Fact] + public void WithBindFileSyncThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => builder.WithBindFileSync(FlagdSource)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void WithBindFileSyncThrowsWhenFileSourceIsNullOrEmpty(string? fileSource) + { + var builder = DistributedApplication.CreateBuilder(); + var flagd = builder.AddFlagd(FlagdName); + + Assert.ThrowsAny(() => flagd.WithBindFileSync(fileSource!)); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/AppHostTests.cs new file mode 100644 index 000000000..80c679b62 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/AppHostTests.cs @@ -0,0 +1,56 @@ +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature.Providers.Ofrep; +using OpenFeature.Providers.Ofrep.Configuration; + +namespace CommunityToolkit.Aspire.Hosting.Flagd.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndRespondsCorrectlyForFlagdEvaluation() + { + var resourceName = "flagd"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(1)); + + var connectionString = await fixture.GetConnectionString(resourceName); + Assert.NotNull(connectionString); + + await OpenFeature.Api.Instance.SetProviderAsync(new FlagdProvider(new Uri(connectionString))); + + var flagClient = OpenFeature.Api.Instance.GetClient(); + var welcomeBanner = await flagClient.GetBooleanDetailsAsync("welcome-banner", false); + var backgroundColor = await flagClient.GetStringDetailsAsync("background-color", "000000"); + var apiVersion = await flagClient.GetStringDetailsAsync("api-version", "0.1"); + + Assert.True(welcomeBanner.Value); + Assert.Equal("#FF0000", backgroundColor.Value); + Assert.Equal("1.0", apiVersion.Value); + } + + [Fact] + public async Task ResourceStartsAndRespondsCorrectlyForOfrepEvaluation() + { + var resourceName = "flagd"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(1)); + + var connectionString = fixture.GetEndpoint(resourceName, "ofrep"); + Assert.NotNull(connectionString); + + // Configure the provider + var config = new OfrepOptions(connectionString.ToString()); + + await OpenFeature.Api.Instance.SetProviderAsync(new OfrepProvider(config)); + + var flagClient = OpenFeature.Api.Instance.GetClient(); + var welcomeBanner = await flagClient.GetBooleanDetailsAsync("welcome-banner", false); + var backgroundColor = await flagClient.GetStringDetailsAsync("background-color", "000000"); + var apiVersion = await flagClient.GetStringDetailsAsync("api-version", "0.1"); + + Assert.True(welcomeBanner.Value); + Assert.Equal("#FF0000", backgroundColor.Value); + Assert.Equal("1.0", apiVersion.Value); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests.csproj new file mode 100644 index 000000000..a3275a343 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests/CommunityToolkit.Aspire.Hosting.Flagd.Tests.csproj @@ -0,0 +1,14 @@ +ο»Ώ + + + + + + + + + + + + +