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 @@
+ο»Ώ
+
+
+
+
+
+
+
+
+
+
+
+
+