Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d628a49
Create Azure Npgsql client integration
sebastienros Mar 20, 2025
89b721d
Reuse code from npgsql and add tests
sebastienros Mar 21, 2025
a4635f3
Update component metadata
sebastienros Mar 21, 2025
7cceac6
Fix tests on CI
sebastienros Mar 21, 2025
f4cf620
Fix markdown
sebastienros Mar 21, 2025
6e9ebad
Improvements on non-azure database support
sebastienros Mar 21, 2025
c4a4d33
Refactor Npgsql usage
sebastienros Mar 22, 2025
bcf8d18
Removing playground app and refactoring
sebastienros Mar 22, 2025
8a6fa32
Update src/Components/Aspire.Azure.Npgsql/AssemblyInfo.cs
sebastienros Mar 24, 2025
81dbd63
Remove config schema metadata
sebastienros Mar 24, 2025
2b5c479
Remove unnecessary file references
sebastienros Mar 24, 2025
21fc9de
Merge remote-tracking branch 'upstream/main' into sebros/azurenpgsql
eerhardt Mar 24, 2025
2ee5339
Comment copies
sebastienros Mar 24, 2025
2f49ff8
Support getting the PrinicipalName for a user assigned managed identity
eerhardt Mar 24, 2025
ee2ee64
Merge remote-tracking branch 'upstream/sebros/azurenpgsql' into sebro…
eerhardt Mar 24, 2025
4f70984
Merge remote-tracking branch 'upstream/main' into sebros/azurenpgsql
eerhardt Mar 24, 2025
25d52db
Merge remote-tracking branch 'origin/sebros/azurenpgsql' into sebros/…
sebastienros Mar 24, 2025
35cbaef
Fix test
sebastienros Mar 24, 2025
1e3b0bd
Remove custom log categories
sebastienros Mar 24, 2025
7091de2
Handle username token extraction failures
sebastienros Mar 26, 2025
9d7e4d2
Fix code blocks
sebastienros Mar 26, 2025
3ea164b
Address PR feedback
eerhardt Mar 26, 2025
32fb819
Fix test project
eerhardt Mar 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions Aspire.sln
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Docker.Tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Kubernetes.Tests", "tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj", "{582B06FD-CC56-4C58-8138-D92F8FE62BBD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql", "src\Components\Aspire.Azure.Npgsql\Aspire.Azure.Npgsql.csproj", "{417C3703-058A-210D-7E9A-28198EFDB25F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql.Tests", "tests\Aspire.Azure.Npgsql.Tests\Aspire.Azure.Npgsql.Tests.csproj", "{201765B1-37A1-9F5B-DD7F-730347046BE1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -3891,6 +3895,30 @@ Global
{582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x64.Build.0 = Release|Any CPU
{582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.ActiveCfg = Release|Any CPU
{582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.Build.0 = Release|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.ActiveCfg = Debug|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.Build.0 = Debug|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x86.ActiveCfg = Debug|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x86.Build.0 = Debug|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Release|Any CPU.Build.0 = Release|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x64.ActiveCfg = Release|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x64.Build.0 = Release|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x86.ActiveCfg = Release|Any CPU
{417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x86.Build.0 = Release|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x64.ActiveCfg = Debug|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x64.Build.0 = Debug|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x86.ActiveCfg = Debug|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x86.Build.0 = Debug|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|Any CPU.Build.0 = Release|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x64.ActiveCfg = Release|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x64.Build.0 = Release|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x86.ActiveCfg = Release|Any CPU
{201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -4210,6 +4238,8 @@ Global
{3AE2ED5B-4EC7-4E6D-A61D-C68B837E5FA7} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47}
{43B560D6-F158-4A4C-8E43-981056EB9038} = {830A89EC-4029-4753-B25A-068BAE37DEC7}
{582B06FD-CC56-4C58-8138-D92F8FE62BBD} = {830A89EC-4029-4753-B25A-068BAE37DEC7}
{417C3703-058A-210D-7E9A-28198EFDB25F} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2}
{201765B1-37A1-9F5B-DD7F-730347046BE1} = {C424395C-1235-41A4-BF55-07880A04368C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4}
Expand Down
26 changes: 26 additions & 0 deletions src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<IsPackable>true</IsPackable>
<PackageTags>$(ComponentAzurePackageTags) postgresql postgres npgsql sql</PackageTags>
<Description>A client for Azure Database for PostgreSQL that integrates with Aspire, including health checks, logging and telemetry.</Description>
<PackageIconFullPath>$(SharedDir)AzurePostgreSQL_256x.png</PackageIconFullPath>
<EnablePackageValidation>false</EnablePackageValidation>
<NoWarn>$(NoWarn);SYSLIB1100;SYSLIB1101</NoWarn>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Common\ConfigurationSchemaAttributes.cs" Link="ConfigurationSchemaAttributes.cs" />
<Compile Include="..\Common\HealthChecksExtensions.cs" Link="HealthChecksExtensions.cs" />
<Compile Include="..\Common\ConnectionStringValidation.cs" Link="ConnectionStringValidation.cs" />
<Compile Include="..\Aspire.Npgsql\NpgsqlCommon.cs" Link="NpgsqlCommon.cs" />
<Compile Include="..\Aspire.Npgsql\AssemblyInfo.LoggingCategories.cs" Link="AssemblyInfo.LoggingCategories.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Azure" />
<ProjectReference Include="..\Aspire.Npgsql\Aspire.Npgsql.csproj" />
</ItemGroup>

</Project>
179 changes: 179 additions & 0 deletions src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#pragma warning disable IDE0130 // Namespace "Microsoft.Extensions.Hosting" does not match folder structure

using System.Diagnostics;
using System.Text.Json;
using Aspire.Azure.Npgsql;
using Aspire.Npgsql;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;

namespace Microsoft.Extensions.Hosting;

/// <summary>
/// Extension methods for connecting to an Azure Database for PostgreSQL with Npgsql client
/// </summary>
public static class AspireAzureNpgsqlExtensions
{
private const string AzureDatabaseForPostgresSqlScope = "https://ossrdbms-aad.database.windows.net/.default";

private static readonly TokenRequestContext s_databaseForPostgresSqlTokenRequestContext = new([AzureDatabaseForPostgresSqlScope]);

/// <summary>
/// Registers <see cref="NpgsqlDataSource"/> service for connecting PostgreSQL database with Npgsql client.
/// Configures health check, logging and telemetry for the Npgsql client.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param>
/// <param name="connectionName">A name used to retrieve the connection string from the ConnectionStrings configuration section.</param>
/// <param name="configureSettings">An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration.</param>
/// <param name="configureDataSourceBuilder">An optional delegate that can be used for customizing the <see cref="NpgsqlDataSourceBuilder"/>.</param>
/// <remarks>Reads the configuration from "Aspire:Npgsql" section.</remarks>
/// <exception cref="ArgumentNullException">Thrown if mandatory <paramref name="builder"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when mandatory <see cref="NpgsqlSettings.ConnectionString"/> is not provided or the <see cref="AzureNpgsqlSettings.Credential"/> is invalid.</exception>
public static void AddAzureNpgsqlDataSource(this IHostApplicationBuilder builder, string connectionName, Action<AzureNpgsqlSettings>? configureSettings = null, Action<NpgsqlDataSourceBuilder>? configureDataSourceBuilder = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(connectionName);

AzureNpgsqlSettings? azureSettings = null;

builder.AddNpgsqlDataSource(connectionName, settings => azureSettings = ConfigureSettings(configureSettings, settings), dataSourceBuilder =>
{
Debug.Assert(azureSettings != null);

ConfigureDataSourceBuilder(azureSettings, dataSourceBuilder);

configureDataSourceBuilder?.Invoke(dataSourceBuilder);
});
}

/// <summary>
/// Registers <see cref="NpgsqlDataSource"/> as a keyed service for given <paramref name="name"/> for connecting PostgreSQL database with Npgsql client.
/// Configures health check, logging and telemetry for the Npgsql client.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param>
/// <param name="name">The name of the component, which is used as the <see cref="ServiceDescriptor.ServiceKey"/> of the service and also to retrieve the connection string from the ConnectionStrings configuration section.</param>
/// <param name="configureSettings">An optional method that can be used for customizing options. It's invoked after the settings are read from the configuration.</param>
/// <param name="configureDataSourceBuilder">An optional delegate that can be used for customizing the <see cref="NpgsqlDataSourceBuilder"/>.</param>
/// <remarks>Reads the configuration from "Aspire:Npgsql:{name}" section.</remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="name"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if mandatory <paramref name="name"/> is empty.</exception>
/// <exception cref="InvalidOperationException">Thrown when mandatory <see cref="NpgsqlSettings.ConnectionString"/> is not provided or the <see cref="AzureNpgsqlSettings.Credential"/> is invalid.</exception>
public static void AddKeyedAzureNpgsqlDataSource(this IHostApplicationBuilder builder, string name, Action<AzureNpgsqlSettings>? configureSettings = null, Action<NpgsqlDataSourceBuilder>? configureDataSourceBuilder = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);

AzureNpgsqlSettings? azureSettings = null;

builder.AddKeyedNpgsqlDataSource(name, settings => azureSettings = ConfigureSettings(configureSettings, settings), dataSourceBuilder =>
{
Debug.Assert(azureSettings != null);

ConfigureDataSourceBuilder(azureSettings, dataSourceBuilder);

configureDataSourceBuilder?.Invoke(dataSourceBuilder);
});
}

private static AzureNpgsqlSettings ConfigureSettings(Action<AzureNpgsqlSettings>? userConfigureSettings, NpgsqlSettings settings)
{
var azureSettings = new AzureNpgsqlSettings();
CopySettings(settings, azureSettings);
userConfigureSettings?.Invoke(azureSettings);
CopySettings(azureSettings, settings);

return azureSettings;
}
private static void CopySettings(NpgsqlSettings source, NpgsqlSettings destination)
{
destination.ConnectionString = source.ConnectionString;
destination.DisableHealthChecks = source.DisableHealthChecks;
destination.DisableMetrics = source.DisableMetrics;
destination.DisableTracing = source.DisableTracing;
}

private static void ConfigureDataSourceBuilder(AzureNpgsqlSettings settings, NpgsqlDataSourceBuilder dataSourceBuilder)
{
// The connection string required the username to be provided. Since it will depend on the Managed Identity that is used
// we attempt to get the username from the access token.

var credential = settings.Credential ?? new DefaultAzureCredential();

if (string.IsNullOrEmpty(dataSourceBuilder.ConnectionStringBuilder.Username))
{
var token = credential.GetToken(s_databaseForPostgresSqlTokenRequestContext, default);

if (TryGetUsernameFromToken(token.Token, out var username))
{
dataSourceBuilder.ConnectionStringBuilder.Username = username;
}
else
{
throw new InvalidOperationException("Could not determine username from token claims");
}
}

if (string.IsNullOrEmpty(dataSourceBuilder.ConnectionStringBuilder.Password))
{
// The token is not cached since it it refreshed for each new physical connection, or when it has expired.

dataSourceBuilder.UsePasswordProvider(
passwordProvider: _ => credential.GetToken(s_databaseForPostgresSqlTokenRequestContext, default).Token,
passwordProviderAsync: async (_, ct) => (await credential.GetTokenAsync(s_databaseForPostgresSqlTokenRequestContext, default).ConfigureAwait(false)).Token
);
}
}

private static bool TryGetUsernameFromToken(string jwtToken, out string? username)
{
username = null;

// Split the token into its parts (Header, Payload, Signature)
var tokenParts = jwtToken.Split('.');
if (tokenParts.Length != 3)
{
return false;
}

// The payload is the second part, Base64Url encoded
var payload = tokenParts[1];

// Add padding if necessary
payload = AddBase64Padding(payload);

// Decode the payload from Base64Url
var decodedBytes = Convert.FromBase64String(payload);

// Parse the decoded payload as JSON
var reader = new Utf8JsonReader(decodedBytes);
var payloadJson = JsonElement.ParseValue(ref reader);

// Try to get the username from 'upn', 'preferred_username', or 'unique_name' claims
if (payloadJson.TryGetProperty("upn", out var upn))
{
username = upn.GetString();
}
else if (payloadJson.TryGetProperty("preferred_username", out var preferredUsername))
{
username = preferredUsername.GetString();
}
else if (payloadJson.TryGetProperty("unique_name", out var uniqueName))
{
username = uniqueName.GetString();
}

return username != null;
}

private static string AddBase64Padding(string base64) => (base64.Length % 4) switch
{
2 => base64 + "==",
3 => base64 + "=",
_ => base64,
};
}
8 changes: 8 additions & 0 deletions src/Components/Aspire.Azure.Npgsql/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire;
using Aspire.Npgsql;
using Azure.Core;

[assembly: ConfigurationSchema("Aspire:Npgsql", typeof(NpgsqlSettings))]
18 changes: 18 additions & 0 deletions src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Npgsql;
using Azure.Core;

namespace Aspire.Azure.Npgsql;

/// <summary>
/// Provides the client configuration settings for connecting to an Azure Database for PostgreSQL using Npgsql.
/// </summary>
public sealed class AzureNpgsqlSettings : NpgsqlSettings
{
/// <summary>
/// Gets or sets the credential used to authenticate to the Azure Database for PostgreSQL namespace.
/// </summary>
public TokenCredential? Credential { get; set; }
}
54 changes: 54 additions & 0 deletions src/Components/Aspire.Azure.Npgsql/ConfigurationSchema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"definitions": {
"logLevel": {
"properties": {
"Npgsql": {
"$ref": "#/definitions/logLevelThreshold"
},
"Npgsql.Command": {
"$ref": "#/definitions/logLevelThreshold"
},
"Npgsql.Connection": {
"$ref": "#/definitions/logLevelThreshold"
},
"Npgsql.Copy": {
"$ref": "#/definitions/logLevelThreshold"
},
"Npgsql.Exception": {
"$ref": "#/definitions/logLevelThreshold"
},
"Npgsql.Replication": {
"$ref": "#/definitions/logLevelThreshold"
},
"Npgsql.Transaction": {
"$ref": "#/definitions/logLevelThreshold"
}
}
}
},
"type": "object",
"properties": {
"Aspire": {
"type": "object",
"properties": {
"Npgsql": {
"type": "object",
"properties": {
"ConnectionString": {
"type": "string"
},
"DisableHealthChecks": {
"type": "boolean"
},
"DisableMetrics": {
"type": "boolean"
},
"DisableTracing": {
"type": "boolean"
}
}
}
}
}
}
}
Loading
Loading