diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4247e0d6..570b0543 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -33,6 +33,7 @@ jobs: Hosting.GoFeatureFlag.Tests, Hosting.Golang.Tests, Hosting.Java.Tests, + Hosting.Keycloak.Extensions.Tests, Hosting.k6.Tests, Hosting.LavinMQ.Tests, Hosting.MailPit.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index ba2cd543..f5a7ccdb 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -58,6 +58,12 @@ + + + + + + @@ -156,8 +162,8 @@ - + @@ -205,6 +211,7 @@ + @@ -249,6 +256,7 @@ + diff --git a/Directory.Build.props b/Directory.Build.props index 9d073609..7fcb80a2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,12 +14,14 @@ 9 $(AspireMajorVersion).5.0 $(AspireVersion) + preview.1.25474.7 9.0.0 9.0.9 1.12.0 4.7.0 9.9.0 false + 4.20.72 $(MSBuildThisFileDirectory) $(RepoRoot)src\Shared diff --git a/Directory.Packages.props b/Directory.Packages.props index 712b83a2..d848a884 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@ true + @@ -105,6 +106,9 @@ + + + diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/AppHost.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/AppHost.cs new file mode 100644 index 00000000..18c1c41e --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/AppHost.cs @@ -0,0 +1,30 @@ +using Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("keycloak-postgres-dev"); +var dbDev = postgres.AddDatabase("db-dev"); + +var keycloakDev = builder.AddKeycloak("keycloak-dev") + .WithPostgres(dbDev); + +var dbUserName = builder.AddParameter("db-username", "postgres"); +var dbPassword = builder.AddParameter("db-password", "Postgres!123"); + +var postgresProd = builder.AddPostgres("postgres-prod", + dbUserName, dbPassword); + +var dbProd = postgresProd.AddDatabase("db-prod"); + +var keycloakProd = builder.AddKeycloak("keycloak-prod") + .WithPostgres(dbProd, dbUserName, dbPassword); + + +builder.AddProject("project-dev") + .WithReference(keycloakDev) + .WaitFor(keycloakDev); + +builder.AddProject("project-prod") + .WithReference(keycloakProd) + .WaitFor(keycloakProd); +builder.Build().Run(); \ No newline at end of file diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost.csproj new file mode 100644 index 00000000..7ce993cb --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + e5e65289-cf91-4bda-b628-28a68fb90841 + + + + + + + + + + + + + + + + diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/Properties/launchSettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..5528d42f --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.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:17044;http://localhost:15216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21202", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22139" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19292", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20099" + } + } + } +} diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/appsettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev.csproj new file mode 100644 index 00000000..597ecc08 --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Keycloak.Extensions.http b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Keycloak.Extensions.http new file mode 100644 index 00000000..197bf286 --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Keycloak.Extensions.http @@ -0,0 +1,6 @@ +@CommunityToolkit.Aspire.Hosting.Keycloak.Extensions_HostAddress = http://localhost:5030 + +GET {{CommunityToolkit.Aspire.Hosting.Keycloak.Extensions_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Program.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Program.cs new file mode 100644 index 00000000..0a40140b --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Program.cs @@ -0,0 +1,45 @@ +using CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +builder.Services.AddAuthentication() + .AddKeycloakJwtBearer("keycloak-dev", "master", jwt => + { + if (builder.Environment.IsDevelopment()) + { + //for development only + jwt.RequireHttpsMetadata = false; + } + }); + +var app = builder.Build(); + + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast"); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Properties/launchSettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Properties/launchSettings.json new file mode 100644 index 00000000..73352f69 --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7041;http://localhost:5030", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/appsettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod.csproj new file mode 100644 index 00000000..cd1583df --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Keycloak.Extensions.Prod.http b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Keycloak.Extensions.Prod.http new file mode 100644 index 00000000..0347bb1d --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Keycloak.Extensions.Prod.http @@ -0,0 +1,6 @@ +@CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod_HostAddress = http://localhost:5286 + +GET {{CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Program.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Program.cs new file mode 100644 index 00000000..26ea0e05 --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Program.cs @@ -0,0 +1,45 @@ +using CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +builder.Services.AddAuthentication() + .AddKeycloakJwtBearer("keycloak-prod", "master", jwt => + { + if (builder.Environment.IsDevelopment()) + { + //for development only + jwt.RequireHttpsMetadata = false; + } + }); + +var app = builder.Build(); + + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => + { + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; + }) + .WithName("GetWeatherForecast"); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} \ No newline at end of file diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Properties/launchSettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Properties/launchSettings.json new file mode 100644 index 00000000..5ec9049c --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5286", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7134;http://localhost:5286", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/appsettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults.csproj new file mode 100644 index 00000000..2a0de1fa --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/Extensions.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..867a90d2 --- /dev/null +++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/Extensions.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, + new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); + } + + return app; + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.csproj b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.csproj new file mode 100644 index 00000000..22414024 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.csproj @@ -0,0 +1,14 @@ + + + + .NET Aspire hosting extensions for Keycloak (includes PostgreSQL integration). + keycloak postgres hosting extensions + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/KeycloakPostgresExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/KeycloakPostgresExtension.cs new file mode 100644 index 00000000..e8206f53 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/KeycloakPostgresExtension.cs @@ -0,0 +1,74 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for integrating Keycloak resources with PostgreSQL. +/// +public static class KeycloakPostgresExtension +{ + private static IResourceBuilder WithPostgresData( + this IResourceBuilder builder, + IResourceBuilder database, + bool xaEnabled = false) + { + var pgServer = database.Resource.Parent; + var ep = pgServer.GetEndpoint("tcp"); + + var dbName = database.Resource.Name; + + var jdbcUrl = ReferenceExpression.Create( + $"jdbc:postgresql://{ep.Property(EndpointProperty.Host)}:{ep.Property(EndpointProperty.Port)}/{dbName}"); + + builder.WithEnvironment("KC_DB", "postgres") + .WithEnvironment("KC_DB_URL", jdbcUrl) + .WithEnvironment("KC_TRANSACTION_XA_ENABLED", xaEnabled.ToString().ToLower()) + .WaitFor(database); + + return builder; + } + + private static ReferenceExpression ToRef(ParameterResource? value) + { + return value is null ? ReferenceExpression.Create($"postgres") : ReferenceExpression.Create($"{value}"); + } + + /// Adds support for Keycloak to connect to a specified Postgres database resource, with optional credentials + /// and configuration for XA transactions. + /// + /// The resource builder for configuring a Keycloak resource. + /// + /// + /// The resource builder for the Postgres database that Keycloak will connect to. + /// + /// + /// (Optional) The resource builder for the parameter defining the database username. + /// If not provided, the parent database resource's username parameter will be used. + /// + /// + /// (Optional) The resource builder for the parameter defining the database password. + /// If not provided, the parent database resource's password parameter will be used. + /// + /// + /// A boolean flag indicating whether XA transactions are enabled. Defaults to false. + /// + /// + /// An updated resource builder with Postgres integration configured for the Keycloak resource. + /// + public static IResourceBuilder WithPostgres( + this IResourceBuilder builder, + IResourceBuilder database, + IResourceBuilder? username = null, + IResourceBuilder? password = null, + bool xaEnabled = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(database); + + return WithPostgresData(builder, database, xaEnabled) + .WithEnvironment("KC_DB_USERNAME", ToRef(username?.Resource ?? + database.Resource.Parent.UserNameParameter)) + .WithEnvironment("KC_DB_PASSWORD", ToRef(password?.Resource ?? + database.Resource.Parent.PasswordParameter)); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/README.md b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/README.md new file mode 100644 index 00000000..847cd540 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/README.md @@ -0,0 +1,56 @@ +# Keycloak Hosting Extensions for .NET Aspire + +## Overview + +This package provides **.NET Aspire hosting extensions** for integrating **Keycloak** with your AppHost. +It includes a PostgreSQL integration that works with resources created via `Aspire.Hosting.Postgres.AddPostgres()` and `Aspire.Hosting.Keycloak.AddKeycloak()`, and automatically configures the required environment variables for Keycloak database connectivity. + +--- + +## Features + +* Configure **Keycloak** to use **PostgreSQL**. +* Automatically sets environment variables: + * `KC_DB` + * `KC_DB_URL` + * `KC_DB_USERNAME` + * `KC_DB_PASSWORD` +* Supports **XA transactions** via `KC_TRANSACTION_XA_ENABLED`. +* Integrates with **ParameterResource** for secure user/password injection. +* Falls back to **default credentials** (`postgres` / `postgres`) if no parameters are provided. +* Fluent **extension methods** on the hosting model. + +--- + +## Usage (AppHost) + +```csharp +var postgres = builder.AddPostgres("pg"); +var db = postgres.AddDatabase("keycloakdb"); + +// Using explicit username/password parameters +var user = builder.AddParameter("keycloak-user"); +var pass = builder.AddParameter("keycloak-pass"); + +var keycloak = builder.AddKeycloak("kc") + .WithPostgres(db, user, pass); + +// Or rely on server parameters or default postgres/postgres +var keycloak2 = builder.AddKeycloak("kc2") + .WithPostgres(db, xaEnabled: true); +``` + +Keycloak will be configured with: + +* `KC_DB=postgres` +* `KC_DB_URL=jdbc:postgresql://:/keycloakdb` +* `KC_DB_USERNAME=` +* `KC_DB_PASSWORD=` +* `KC_TRANSACTION_XA_ENABLED=true` (when set) + +--- + +## Notes + +* Extension methods are in the `Aspire.Hosting` namespace for discoverability in AppHost projects. +* If you add custom resource types, place them under `Aspire.Hosting.ApplicationModel`. \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests.csproj new file mode 100644 index 00000000..916a2085 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakExtensionTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakExtensionTests.cs new file mode 100644 index 00000000..2c1072d3 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakExtensionTests.cs @@ -0,0 +1,121 @@ +using Aspire.Hosting; +using Moq; + +namespace CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests; + +public class KeycloakExtensionTests +{ + private static async Task> GetEnv(IResourceBuilder kc) + { + return await kc.Resource.GetEnvironmentVariableValuesAsync(); + } + + [Fact] + public void WithPostgresDev_Should_Throw_If_Builder_Is_Null() + { + IDistributedApplicationBuilder builder = null!; + + var act = () => builder.AddKeycloak("testkeycloak") + .WithPostgres(null!); + + var exeption = Assert.Throws(act); + Assert.Equal("builder", exeption.ParamName); + } + + [Fact] + public void WithPostgresDev_Should_Throw_If_Database_Is_Null() + { + var app = DistributedApplication.CreateBuilder(); + Assert.Throws(() => + app.AddKeycloak("testkeycloak") + .WithPostgres(null!)); + } + + [Fact] + public void WithPostgres_ExplicitCredentials_NullBuilder_Throws() + { + Assert.Throws(() => + KeycloakPostgresExtension.WithPostgres(null!, + new Mock>().Object, + new Mock>().Object, + new Mock>().Object)); + } + + [Fact] + public async Task WithPostgres_Defaults_SetBasicVars() + { + var app = DistributedApplication.CreateBuilder(); + var pg = app.AddPostgres("pg"); + var db = pg.AddDatabase("keycloakdb"); + var kc = app.AddKeycloak("kc") + .WithPostgres(db); + + var env = await GetEnv(kc); + + Assert.Equal("postgres", env["KC_DB"]); + Assert.True(env.ContainsKey("KC_DB_URL")); + var url = env["KC_DB_URL"]; + Assert.StartsWith("jdbc:postgresql://", url); + Assert.EndsWith("/keycloakdb", url); + } + + [Fact] + public async Task WithPostgres_ExplicitParameters_AreUsed() + { + var app = DistributedApplication.CreateBuilder(); + + var pg = app.AddPostgres("pg"); + var db = pg.AddDatabase("keycloakdb"); + var user = app.AddParameter("kc-user"); + var pass = app.AddParameter("kc-pass"); + + var kc = app.AddKeycloak("kc") + .WithPostgres(db, user, pass); + + var env = await GetEnv(kc); + + Assert.False(ReferenceEquals(user.Resource, env["KC_DB_USERNAME"])); + Assert.False(ReferenceEquals(pass.Resource, env["KC_DB_PASSWORD"])); + Assert.True(env.ContainsKey("KC_DB_USERNAME")); + Assert.True(env.ContainsKey("KC_DB_PASSWORD")); + } + + [Fact] + public async Task WithPostgres_Uses_ServerParameters_When_Present() + { + var app = DistributedApplication.CreateBuilder(); + + var pg = app.AddPostgres("pg"); + var username = app.AddParameter("pg-user"); + var pass = app.AddParameter("pg-pass"); + + pg.WithUserName(username) + .WithPassword(pass); + + var db = pg.AddDatabase("keycloakdb"); + + var kc = app.AddKeycloak("kc") + .WithPostgres(db); + + var env = await GetEnv(kc); + Assert.NotEqual("postgres", env["KC_DB_USERNAME"].ToString()); + Assert.NotEqual("postgres", env["KC_DB_PASSWORD"].ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithPostgres_XA_Flag_Set_When_Enabled(bool xaEnabled) + { + var app = DistributedApplication.CreateBuilder(); + + var pg = app.AddPostgres("pg"); + var db = pg.AddDatabase("keycloakdb"); + + var kc = app.AddKeycloak("kc") + .WithPostgres(db, xaEnabled: xaEnabled); + + var env = await GetEnv(kc); + Assert.Equal(xaEnabled.ToString().ToLower(), env["KC_TRANSACTION_XA_ENABLED"]); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakWithPostgresIntegrationTest.cs b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakWithPostgresIntegrationTest.cs new file mode 100644 index 00000000..a295d9f9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakWithPostgresIntegrationTest.cs @@ -0,0 +1,84 @@ +using Aspire.Components.Common.Tests; +using Aspire.Hosting; +using Aspire.Hosting.Utils; + +namespace CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests; + +[RequiresDocker] +public class KeycloakWithPostgresIntegrationTest +{ + [Fact] + public async Task Keycloak_WithPostgres_Starts_And_HealthReady_Ok() + { + using var builder = TestDistributedApplicationBuilder.Create(); + var pg = builder.AddPostgres("pg"); + var db = pg.AddDatabase("keycloakdb"); + + var kc = builder.AddKeycloak("kc") + .WithPostgres(db); + + await using var app = await builder.BuildAsync(); + + await app.StartAsync(); + + await app.ResourceNotifications.WaitForResourceAsync(pg.Resource.Name, KnownResourceStates.Running) + .WaitAsync(TimeSpan.FromMinutes(3)); + await app.ResourceNotifications.WaitForResourceAsync(kc.Resource.Name, KnownResourceStates.Running) + .WaitAsync(TimeSpan.FromMinutes(5)); + + await app.ResourceNotifications + .WaitForResourceHealthyAsync(pg.Resource.Name) + .WaitAsync(TimeSpan.FromMinutes(3)); + + + await app.ResourceNotifications + .WaitForResourceHealthyAsync(kc.Resource.Name) + .WaitAsync(TimeSpan.FromMinutes(5)); + + + using var http = app.CreateHttpClient(kc.Resource.Name, "management"); + var response = await http.GetAsync("/health/ready"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await app.StopAsync(); + } + + [Fact] + public async Task Keycloak_WithPostgres_Env_Is_Applied_And_DbUrl_Is_Valid() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var pg = builder.AddPostgres("pg"); + var db = pg.AddDatabase("keycloakdb"); + + var kc = builder.AddKeycloak("kc") + .WithPostgres(db); + + await using var app = await builder.BuildAsync(); + await app.StartAsync(); + + await app.ResourceNotifications.WaitForResourceAsync(pg.Resource.Name, KnownResourceStates.Running) + .WaitAsync(TimeSpan.FromMinutes(3)); + await app.ResourceNotifications.WaitForResourceAsync(kc.Resource.Name, KnownResourceStates.Running) + .WaitAsync(TimeSpan.FromMinutes(5)); + + await app.ResourceNotifications + .WaitForResourceHealthyAsync(pg.Resource.Name) + .WaitAsync(TimeSpan.FromMinutes(3)); + + + await app.ResourceNotifications + .WaitForResourceHealthyAsync(kc.Resource.Name) + .WaitAsync(TimeSpan.FromMinutes(5)); + + var env = await kc.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal("postgres", env["KC_DB"]); + Assert.True(env.ContainsKey("KC_DB_URL")); + Assert.StartsWith("jdbc:postgresql://", env["KC_DB_URL"]); + Assert.EndsWith("/keycloakdb", env["KC_DB_URL"]); + + await app.StopAsync(); + } +} \ No newline at end of file