diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e178e4885..3fd8a95c8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -39,6 +39,7 @@ jobs: Hosting.Meilisearch.Tests, Hosting.MongoDB.Extensions.Tests, Hosting.MySql.Extensions.Tests, + Hosting.Neon.Tests, Hosting.Ngrok.Tests, Hosting.NodeJS.Extensions.Tests, Hosting.Ollama.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index b27af11f5..1baa6c388 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -92,6 +92,11 @@ + + + + + @@ -177,6 +182,7 @@ + @@ -198,6 +204,7 @@ + @@ -228,6 +235,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 9222db74c..515964304 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/CommunityToolkit.Aspire.Hosting.Neon.ApiService.csproj b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/CommunityToolkit.Aspire.Hosting.Neon.ApiService.csproj new file mode 100644 index 000000000..6f3311664 --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/CommunityToolkit.Aspire.Hosting.Neon.ApiService.csproj @@ -0,0 +1,13 @@ + + + + enable + enable + + + + + + + + diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/Program.cs b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/Program.cs new file mode 100644 index 000000000..ea500633b --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/Program.cs @@ -0,0 +1,24 @@ +using Npgsql; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddNeonDataSource("neondb"); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/", () => "Neon API Service"); + +app.MapGet("/test", async (NpgsqlDataSource dataSource) => +{ + await using var connection = await dataSource.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT version()"; + var version = await command.ExecuteScalarAsync(); + return new { version, message = "Connected to Neon successfully!" }; +}); + +app.Run(); diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/Properties/launchSettings.json b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/Properties/launchSettings.json new file mode 100644 index 000000000..df85fcaf4 --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/appsettings.json b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/CommunityToolkit.Aspire.Hosting.Neon.AppHost.csproj b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/CommunityToolkit.Aspire.Hosting.Neon.AppHost.csproj new file mode 100644 index 000000000..bf2d8023d --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/CommunityToolkit.Aspire.Hosting.Neon.AppHost.csproj @@ -0,0 +1,21 @@ + + + + + Exe + enable + enable + true + neon-example-apphost-secrets + + + + + + + + + + + + diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/Program.cs b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/Program.cs new file mode 100644 index 000000000..5a6dcac9a --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/Program.cs @@ -0,0 +1,15 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Add Neon project resource +var neon = builder.AddNeonProject("neon"); + +// Add a Neon database +var neonDb = neon.AddDatabase("neondb"); + +// Reference the Neon database in a project +builder.AddProject("apiservice") + .WithReference(neonDb) + .WaitFor(neonDb) + .WithHttpHealthCheck("/health"); + +builder.Build().Run(); diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/Properties/launchSettings.json b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..0f18f759d --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.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:17052;http://localhost:15125", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21025", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22191" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15125", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19091", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20167" + } + } + } +} diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults.csproj b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults.csproj new file mode 100644 index 000000000..6f00332d1 --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + Library + enable + enable + true + + + + + + + + + + + + + + + diff --git a/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults/Extensions.cs b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..b34d76254 --- /dev/null +++ b/examples/neon/CommunityToolkit.Aspire.Hosting.Neon.ServiceDefaults/Extensions.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; +// 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 +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + 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 IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + 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 IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + 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("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/CommunityToolkit.Aspire.Hosting.Neon.csproj b/src/CommunityToolkit.Aspire.Hosting.Neon/CommunityToolkit.Aspire.Hosting.Neon.csproj new file mode 100644 index 000000000..3580f5d70 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/CommunityToolkit.Aspire.Hosting.Neon.csproj @@ -0,0 +1,12 @@ + + + + hosting neon postgresql + Neon support for .NET Aspire. + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/NeonBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonBuilderExtensions.cs new file mode 100644 index 000000000..a2a0eeb9b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonBuilderExtensions.cs @@ -0,0 +1,169 @@ +using CommunityToolkit.Aspire.Hosting.Neon; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Neon resources to the application model. +/// +public static class NeonBuilderExtensions +{ + private const int NeonPort = 5432; + private const string DefaultUserName = "postgres"; + + /// + /// Adds a Neon project resource to the application model. + /// The default image is and the tag is . + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The parameter used to provide the user name for the Neon project. If a default user name will be used. + /// The parameter used to provide the password for the Neon project. If a random password will be generated. + /// The host port to bind the underlying container to. + /// A reference to the . + /// + /// + /// Add a Neon project to the application model and reference it in a .NET project. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var neon = builder.AddNeonProject("neon"); + /// var db = neon.AddDatabase("neondb"); + /// + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(db); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddNeonProject( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder? userName = null, + IResourceBuilder? password = null, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var userNameParameter = userName?.Resource; + if (userNameParameter == null) + { + var userNameBuilder = builder.AddParameter($"{name}-username", secret: false); + builder.Configuration[$"Parameters:{name}-username"] = DefaultUserName; + userNameParameter = userNameBuilder.Resource; + } + + var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password"); + + var neonProject = new NeonProjectResource(name, userNameParameter, passwordParameter); + + return builder.AddResource(neonProject) + .WithImage(NeonContainerImageTags.Image, NeonContainerImageTags.Tag) + .WithImageRegistry(NeonContainerImageTags.Registry) + .WithEndpoint(targetPort: NeonPort, port: port, name: NeonProjectResource.PrimaryEndpointName) + .WithEnvironment(context => + { + context.EnvironmentVariables["POSTGRES_USER"] = neonProject.UserNameParameter; + context.EnvironmentVariables["POSTGRES_PASSWORD"] = neonProject.PasswordParameter; + }); + } + + /// + /// Adds a Neon database resource to the application model. + /// + /// The Neon project resource builder. + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The name of the database. If not provided, this defaults to the same value as . + /// A reference to the . + /// + /// + /// Add a Neon project with a database to the application model. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var neon = builder.AddNeonProject("neon") + /// .AddDatabase("neondb"); + /// + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(neon); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddDatabase( + this IResourceBuilder builder, + [ResourceName] string name, + string? databaseName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var databaseResource = new NeonDatabaseResource(name, databaseName ?? name, builder.Resource); + + return builder.ApplicationBuilder.AddResource(databaseResource); + } + + /// + /// Adds a named volume for the data folder to a Neon project resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// The . + /// + /// + /// Add a Neon project to the application model with a data volume to persist data across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var neon = builder.AddNeonProject("neon") + /// .WithDataVolume(); + /// var db = neon.AddDatabase("neondb"); + /// + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(db); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/var/lib/postgresql/data"); + } + + /// + /// Adds a bind mount for the data folder to a Neon project resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// The . + /// + /// + /// Add a Neon project to the application model with a data bind mount to persist data across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var neon = builder.AddNeonProject("neon") + /// .WithDataBindMount("./data/neon"); + /// var db = neon.AddDatabase("neondb"); + /// + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(db); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + return builder.WithBindMount(source, "/var/lib/postgresql/data"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/NeonContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonContainerImageTags.cs new file mode 100644 index 000000000..ed7c0e4f4 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonContainerImageTags.cs @@ -0,0 +1,11 @@ +namespace CommunityToolkit.Aspire.Hosting.Neon; + +internal static class NeonContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + /// neondatabase/neon + public const string Image = "neondatabase/neon"; + /// latest + public const string Tag = "latest"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/NeonDatabaseResource.cs b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonDatabaseResource.cs new file mode 100644 index 000000000..33f859f4c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonDatabaseResource.cs @@ -0,0 +1,35 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Neon database. +/// +public class NeonDatabaseResource : Resource, IResourceWithConnectionString, IResourceWithParent +{ + /// The name of the resource. + /// The database name. + /// The Neon project resource associated with this database. + public NeonDatabaseResource(string name, string databaseName, NeonProjectResource parent) : base(name) + { + ArgumentNullException.ThrowIfNull(parent); + ArgumentException.ThrowIfNullOrEmpty(databaseName); + + Parent = parent; + DatabaseName = databaseName; + } + + /// + /// Gets the parent Neon project resource. + /// + public NeonProjectResource Parent { get; } + + /// + /// Gets the database name. + /// + public string DatabaseName { get; } + + /// + /// Gets the connection string expression for the Neon database. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"{Parent.ConnectionStringExpression};Database={DatabaseName}"); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/NeonProjectResource.cs b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonProjectResource.cs new file mode 100644 index 000000000..744530a65 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/NeonProjectResource.cs @@ -0,0 +1,44 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Neon project. +/// +public class NeonProjectResource : ContainerResource, IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "tcp"; + + /// The name of the resource. + /// A parameter that contains the Neon user name. + /// A parameter that contains the Neon password. + public NeonProjectResource(string name, ParameterResource userName, ParameterResource password) : base(name) + { + ArgumentNullException.ThrowIfNull(userName); + ArgumentNullException.ThrowIfNull(password); + + UserNameParameter = userName; + PasswordParameter = password; + } + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the Neon project. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the parameter that contains the Neon user name. + /// + public ParameterResource UserNameParameter { get; } + + /// + /// Gets the parameter that contains the Neon password. + /// + public ParameterResource PasswordParameter { get; } + + /// + /// Gets the connection string expression for the Neon project. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"Host={PrimaryEndpoint.Property(EndpointProperty.Host)};Port={PrimaryEndpoint.Property(EndpointProperty.Port)};Username={UserNameParameter};Password={PasswordParameter}"); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/README.md b/src/CommunityToolkit.Aspire.Hosting.Neon/README.md new file mode 100644 index 000000000..58b09d5ae --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/README.md @@ -0,0 +1,69 @@ +# CommunityToolkit.Aspire.Hosting.Neon library + +This integration provides support for [Neon](https://neon.tech/), a serverless PostgreSQL-compatible database, in .NET Aspire applications. + +## Getting Started + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.Neon +``` + +### Example usage + +In the _Program.cs_ file of your AppHost project, add a Neon project and database: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// Add Neon project resource +var neon = builder.AddNeonProject("neon"); + +// Add a Neon database +var neonDb = neon.AddDatabase("neondb"); + +// Reference the Neon database in a project +var exampleProject = builder.AddProject() + .WithReference(neonDb); + +builder.Build().Run(); +``` + +## Additional Configuration + +### Data Persistence + +To persist data across container restarts, you can use either a volume or bind mount: + +```csharp +var neon = builder.AddNeonProject("neon") + .WithDataVolume(); // or .WithDataBindMount("./data/neon") +``` + +### Custom Credentials + +You can provide custom credentials for the Neon project: + +```csharp +var userName = builder.AddParameter("neon-user"); +var password = builder.AddParameter("neon-password", secret: true); + +var neon = builder.AddNeonProject("neon", userName, password); +``` + +## About Neon Features + +This integration provides a PostgreSQL-compatible container for local development. For production scenarios using Neon's cloud service, you can use the Neon connection string directly with the standard [Aspire PostgreSQL](https://learn.microsoft.com/dotnet/aspire/database/postgresql-component) or [Aspire Npgsql](https://learn.microsoft.com/dotnet/aspire/database/postgresql-entity-framework-component) integrations. + +Neon's unique features like [database branching](https://neon.tech/docs/introduction/branching) are available when using Neon's cloud service and can be managed through their API or CLI alongside this integration. + +## Additional Information + +https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-neon + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/src/CommunityToolkit.Aspire.Hosting.Neon/api/CommunityToolkit.Aspire.Hosting.Neon.cs b/src/CommunityToolkit.Aspire.Hosting.Neon/api/CommunityToolkit.Aspire.Hosting.Neon.cs new file mode 100644 index 000000000..38fb7bdbc --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Neon/api/CommunityToolkit.Aspire.Hosting.Neon.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace Aspire.Hosting +{ + public static partial class NeonBuilderExtensions + { + public static ApplicationModel.IResourceBuilder AddDatabase(this ApplicationModel.IResourceBuilder builder, string name, string? databaseName = null) { throw null; } + + public static ApplicationModel.IResourceBuilder AddNeonProject(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder? userName = null, ApplicationModel.IResourceBuilder? password = null, int? port = null) { throw null; } + + public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; } + + public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; } + } +} + +namespace Aspire.Hosting.ApplicationModel +{ + public partial class NeonDatabaseResource : Resource, IResourceWithConnectionString, IResourceWithParent, IResource, IManifestExpressionProvider, IValueProvider, IValueWithReferences + { + public NeonDatabaseResource(string name, string databaseName, NeonProjectResource parent) : base(default!) { } + + public ReferenceExpression ConnectionStringExpression { get { throw null; } } + + public string DatabaseName { get { throw null; } } + + public NeonProjectResource Parent { get { throw null; } } + } + + public partial class NeonProjectResource : ContainerResource, IResourceWithConnectionString, IResource, IManifestExpressionProvider, IValueProvider, IValueWithReferences + { + public NeonProjectResource(string name, ParameterResource userName, ParameterResource password) : base(default!, default) { } + + public ReferenceExpression ConnectionStringExpression { get { throw null; } } + + public ParameterResource PasswordParameter { get { throw null; } } + + public EndpointReference PrimaryEndpoint { get { throw null; } } + + public ParameterResource UserNameParameter { get { throw null; } } + } +} diff --git a/src/CommunityToolkit.Aspire.Neon/CommunityToolkit.Aspire.Neon.csproj b/src/CommunityToolkit.Aspire.Neon/CommunityToolkit.Aspire.Neon.csproj new file mode 100644 index 000000000..4a2922e19 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Neon/CommunityToolkit.Aspire.Neon.csproj @@ -0,0 +1,12 @@ + + + + neon postgresql npgsql + Neon client support for .NET Aspire. + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Neon/NeonExtensions.cs b/src/CommunityToolkit.Aspire.Neon/NeonExtensions.cs new file mode 100644 index 000000000..7cd177747 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Neon/NeonExtensions.cs @@ -0,0 +1,71 @@ +using CommunityToolkit.Aspire.Neon; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for adding Neon database client to an . +/// +public static class NeonExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Neon"; + + /// + /// Registers an in the DI container for connecting to a Neon database. + /// + /// The to read config from and add services to. + /// The name of the connection string in the configuration. + /// An optional delegate that can be used for customizing the . + /// An optional delegate that can be used for customizing the . + /// + /// + /// Add a Neon data source to the service collection. + /// + /// var builder = WebApplication.CreateBuilder(args); + /// + /// builder.AddNeonDataSource("neondb"); + /// + /// var app = builder.Build(); + /// + /// + /// + public static void AddNeonDataSource( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action? configureDataSourceBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + var configSectionPath = $"{DefaultConfigSectionName}:{connectionName}"; + var configSection = builder.Configuration.GetSection(configSectionPath); + + var settings = new NeonSettings(); + configSection.Bind(settings); + configureSettings?.Invoke(settings); + + if (string.IsNullOrEmpty(settings.ConnectionString)) + { + settings.ConnectionString = builder.Configuration.GetConnectionString(connectionName); + } + + if (string.IsNullOrEmpty(settings.ConnectionString)) + { + throw new InvalidOperationException($"Connection string '{connectionName}' not found."); + } + + builder.AddNpgsqlDataSource( + connectionName, + configureSettings: npgsqlSettings => + { + npgsqlSettings.ConnectionString = settings.ConnectionString; + npgsqlSettings.DisableHealthChecks = settings.DisableHealthChecks; + npgsqlSettings.DisableTracing = settings.DisableTracing; + npgsqlSettings.DisableMetrics = settings.DisableMetrics; + }, + configureDataSourceBuilder: configureDataSourceBuilder); + } +} diff --git a/src/CommunityToolkit.Aspire.Neon/NeonSettings.cs b/src/CommunityToolkit.Aspire.Neon/NeonSettings.cs new file mode 100644 index 000000000..f64a33fc2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Neon/NeonSettings.cs @@ -0,0 +1,36 @@ +namespace CommunityToolkit.Aspire.Neon; + +/// +/// Provides the client configuration settings for connecting to a Neon database. +/// +public sealed class NeonSettings +{ + /// + /// Gets or sets the connection string to use for connecting to the Neon database. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the database health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableMetrics { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Neon/README.md b/src/CommunityToolkit.Aspire.Neon/README.md new file mode 100644 index 000000000..2116f39c5 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Neon/README.md @@ -0,0 +1,87 @@ +# CommunityToolkit.Aspire.Neon library + +This integration provides PostgreSQL client support for .NET Aspire applications, compatible with both local PostgreSQL containers and Neon's serverless PostgreSQL service. It leverages the Npgsql library for connectivity. + +## Getting Started + +### Install the package + +In your client project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Neon +``` + +### Example usage + +In the _Program.cs_ file of your client project, add the Neon data source: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Add Neon data source +builder.AddNeonDataSource("neondb"); + +var app = builder.Build(); +``` + +You can then retrieve the `NpgsqlDataSource` instance using dependency injection: + +```csharp +public class MyService +{ + private readonly NpgsqlDataSource _dataSource; + + public MyService(NpgsqlDataSource dataSource) + { + _dataSource = dataSource; + } + + public async Task GetCountAsync() + { + await using var connection = await _dataSource.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM users"; + return Convert.ToInt32(await command.ExecuteScalarAsync()); + } +} +``` + +## Configuration + +The Neon client integration supports the following configuration options: + +```json +{ + "Aspire": { + "Neon": { + "neondb": { + "ConnectionString": "Host=myhost;Database=mydb;Username=myuser;Password=mypass", + "DisableHealthChecks": false, + "DisableTracing": false, + "DisableMetrics": false + } + } + } +} +``` + +## Using with Neon Cloud Service + +For production scenarios using Neon's cloud service, provide your Neon connection string in the configuration: + +```json +{ + "ConnectionStrings": { + "neondb": "postgresql://user:password@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb?sslmode=require" + } +} +``` + +## Additional Information + +https://learn.microsoft.com/dotnet/aspire/community-toolkit/neon + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/src/CommunityToolkit.Aspire.Neon/api/CommunityToolkit.Aspire.Neon.cs b/src/CommunityToolkit.Aspire.Neon/api/CommunityToolkit.Aspire.Neon.cs new file mode 100644 index 000000000..7da7577f2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Neon/api/CommunityToolkit.Aspire.Neon.cs @@ -0,0 +1,29 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +namespace CommunityToolkit.Aspire.Neon +{ + public sealed partial class NeonSettings + { + public string? ConnectionString { get { throw null; } set { } } + + public bool DisableHealthChecks { get { throw null; } set { } } + + public bool DisableMetrics { get { throw null; } set { } } + + public bool DisableTracing { get { throw null; } set { } } + } +} + +namespace Microsoft.Extensions.Hosting +{ + public static partial class NeonExtensions + { + public static void AddNeonDataSource(this IHostApplicationBuilder builder, string connectionName, System.Action? configureSettings = null, System.Action? configureDataSourceBuilder = null) { } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/AddNeonTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/AddNeonTests.cs new file mode 100644 index 000000000..b4fbd3109 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/AddNeonTests.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace CommunityToolkit.Aspire.Hosting.Neon.Tests; + +public class AddNeonTests +{ + [Fact] + public async Task AddNeonProjectWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + var neon = appBuilder.AddNeonProject("neon"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("neon", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Single(endpoints); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "tcp"); + Assert.Equal(5432, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("tcp", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("tcp", primaryEndpoint.Transport); + Assert.Equal("tcp", primaryEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(NeonContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(NeonContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(NeonContainerImageTags.Registry, containerAnnotation.Registry); + + var config = await neon.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal(2, config.Count()); + Assert.Contains(config, c => c.Key == "POSTGRES_USER"); + Assert.Contains(config, c => c.Key == "POSTGRES_PASSWORD"); + } + + [Fact] + public async Task AddNeonProjectAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var userName = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(appBuilder, $"userName", special: false); + var password = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(appBuilder, $"password"); + + appBuilder.Configuration["Parameters:userName"] = await userName.GetValueAsync(default); + appBuilder.Configuration["Parameters:password"] = await password.GetValueAsync(default); + + var userNameParameter = appBuilder.AddParameter(userName.Name); + var passwordParameter = appBuilder.AddParameter(password.Name); + var neon = appBuilder.AddNeonProject("neon", userNameParameter, passwordParameter); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("neon", containerResource.Name); + + var endpoints = containerResource.Annotations.OfType(); + Assert.Single(endpoints); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "tcp"); + Assert.Equal(5432, primaryEndpoint.TargetPort); + Assert.False(primaryEndpoint.IsExternal); + Assert.Equal("tcp", primaryEndpoint.Name); + Assert.Null(primaryEndpoint.Port); + Assert.Equal(ProtocolType.Tcp, primaryEndpoint.Protocol); + Assert.Equal("tcp", primaryEndpoint.Transport); + Assert.Equal("tcp", primaryEndpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(NeonContainerImageTags.Tag, containerAnnotation.Tag); + Assert.Equal(NeonContainerImageTags.Image, containerAnnotation.Image); + Assert.Equal(NeonContainerImageTags.Registry, containerAnnotation.Registry); + + var config = await neon.Resource.GetEnvironmentVariableValuesAsync(); + + Assert.Equal(2, config.Count()); + Assert.Contains(config, c => c.Key == "POSTGRES_USER"); + Assert.Contains(config, c => c.Key == "POSTGRES_PASSWORD"); + } + + [Fact] + public async Task NeonCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var neon = appBuilder + .AddNeonProject("neon") + .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432)); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()) as IResourceWithConnectionString; + var connectionString = await connectionStringResource.GetConnectionStringAsync(); + + var expectedUserName = await neon.Resource.UserNameParameter.GetValueAsync(default); + var expectedPassword = await neon.Resource.PasswordParameter.GetValueAsync(default); + + Assert.Equal($"Host=localhost;Port=5432;Username={expectedUserName};Password={expectedPassword}", connectionString); + } + + [Fact] + public void AddDatabaseAddsNeonDatabaseResource() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var neon = appBuilder.AddNeonProject("neon"); + + var db = neon.AddDatabase("mydb", "actual-db-name"); + + using var app = appBuilder.Build(); + var appModel = app.Services.GetRequiredService(); + + var dbResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("mydb", dbResource.Name); + Assert.Equal("actual-db-name", dbResource.DatabaseName); + Assert.Equal(neon.Resource, dbResource.Parent); + } + + [Fact] + public async Task NeonDatabaseCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var neon = appBuilder + .AddNeonProject("neon") + .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432)); + + var db = neon.AddDatabase("mydb"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()) as IResourceWithConnectionString; + var connectionString = await connectionStringResource.GetConnectionStringAsync(); + + var expectedUserName = await neon.Resource.UserNameParameter.GetValueAsync(default); + var expectedPassword = await neon.Resource.PasswordParameter.GetValueAsync(default); + + Assert.Contains("Host=localhost", connectionString); + Assert.Contains("Port=5432", connectionString); + Assert.Contains($"Username={expectedUserName}", connectionString); + Assert.Contains($"Password={expectedPassword}", connectionString); + Assert.Contains("Database=mydb", connectionString); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/CommunityToolkit.Aspire.Hosting.Neon.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/CommunityToolkit.Aspire.Hosting.Neon.Tests.csproj new file mode 100644 index 000000000..e5ebb55a5 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/CommunityToolkit.Aspire.Hosting.Neon.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/NeonPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/NeonPublicApiTests.cs new file mode 100644 index 000000000..69f23f4a9 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.Neon.Tests/NeonPublicApiTests.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.Neon.Tests; + +[RequiresDocker] +public class NeonPublicApiTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndRespondsOk() + { + var appName = "neon"; + var httpClient = fixture.CreateHttpClient(appName); + + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(appName).WaitAsync(TimeSpan.FromMinutes(5)); + + var response = await httpClient.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +}