Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// Containerized resources.
var db5 = builder.AddPostgres("pg4").WithPgAdmin().PublishAsContainer().AddDatabase("db5");
var db6 = builder.AddPostgres("pg5").WithPgAdmin().PublishAsContainer().AddDatabase("db6");
var pg6 = builder.AddPostgres("pg6").WithPgAdmin(c => c.WithHostPort(8999).WithImageTag("8.3")).PublishAsContainer();
var pg6 = builder.AddPostgres("pg6").WithPgAdmin(c => c.WithHostPort(8999)).PublishAsContainer();
var db7 = pg6.AddDatabase("db7");
var db8 = pg6.AddDatabase("db8");
var db9 = pg6.AddDatabase("db9", "db8"); // different connection string (db9) on same database as db8
Expand Down
94 changes: 92 additions & 2 deletions src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using Aspire.Hosting.Postgres;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Npgsql;

namespace Aspire.Hosting;

Expand Down Expand Up @@ -67,6 +69,32 @@ public static IResourceBuilder<PostgresServerResource> AddPostgres(this IDistrib
}
});

builder.Eventing.Subscribe<ResourceReadyEvent>(postgresServer, async (@event, ct) =>
{
if (connectionString is null)
{
throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{postgresServer.Name}' resource but the connection string was null.");
}

// Non-database scoped connection string
using var npgsqlConnection = new NpgsqlConnection(connectionString + ";Database=postgres;");

await npgsqlConnection.OpenAsync(ct).ConfigureAwait(false);

if (npgsqlConnection.State != System.Data.ConnectionState.Open)
{
throw new InvalidOperationException($"Could not open connection to '{postgresServer.Name}'");
}

foreach (var name in postgresServer.Databases.Keys)
{
if (builder.Resources.FirstOrDefault(n => string.Equals(n.Name, name, StringComparisons.ResourceName)) is PostgresDatabaseResource postgreDatabase)
{
await CreateDatabaseAsync(npgsqlConnection, postgreDatabase, @event.Services, ct).ConfigureAwait(false);
}
}
});

var healthCheckKey = $"{name}_check";
builder.Services.AddHealthChecks().AddNpgSql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey, configure: (connection) =>
{
Expand Down Expand Up @@ -121,9 +149,28 @@ public static IResourceBuilder<PostgresDatabaseResource> AddDatabase(this IResou
// Use the resource name as the database name if it's not provided
databaseName ??= name;

builder.Resource.AddDatabase(name, databaseName);
var postgresDatabase = new PostgresDatabaseResource(name, databaseName, builder.Resource);
return builder.ApplicationBuilder.AddResource(postgresDatabase);

builder.Resource.AddDatabase(postgresDatabase.Name, postgresDatabase.DatabaseName);

string? connectionString = null;

builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(postgresDatabase, async (@event, ct) =>
{
connectionString = await postgresDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);

if (connectionString == null)
{
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null.");
}
});

var healthCheckKey = $"{name}_check";
builder.ApplicationBuilder.Services.AddHealthChecks().AddNpgSql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey);

return builder.ApplicationBuilder
.AddResource(postgresDatabase)
.WithHealthCheck(healthCheckKey);
}

/// <summary>
Expand Down Expand Up @@ -418,6 +465,27 @@ public static IResourceBuilder<PostgresServerResource> WithInitBindMount(this IR
return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
}

/// <summary>
/// Defines the SQL script used to create the database.
/// </summary>
/// <param name="builder">The builder for the <see cref="PostgresDatabaseResource"/>.</param>
/// <param name="script">The SQL script used to create the database.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// The script can only contain SQL statements applying to the default database like CREATE DATABASE. Custom statements like table creation
/// and data insertion are not supported since they require a distinct connection to the newly created database.
/// <value>Default script is <code>CREATE DATABASE "&lt;QUOTED_DATABASE_NAME&gt;"</code></value>
/// </remarks>
public static IResourceBuilder<PostgresDatabaseResource> WithCreationScript(this IResourceBuilder<PostgresDatabaseResource> builder, string script)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(script);

builder.WithAnnotation(new CreationScriptAnnotation(script));

return builder;
}

private static string WritePgWebBookmarks(IEnumerable<PostgresDatabaseResource> postgresInstances, out byte[] contentHash)
{
var dir = Directory.CreateTempSubdirectory().FullName;
Expand Down Expand Up @@ -488,4 +556,26 @@ private static string WritePgAdminServerJson(IEnumerable<PostgresServerResource>

return filePath;
}

private static async Task CreateDatabaseAsync(NpgsqlConnection npgsqlConnection, PostgresDatabaseResource npgsqlDatabase, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var scriptAnnotation = npgsqlDatabase.Annotations.OfType<CreationScriptAnnotation>().LastOrDefault();

try
{
var quotedDatabaseIdentifier = new NpgsqlCommandBuilder().QuoteIdentifier(npgsqlDatabase.DatabaseName);
using var command = npgsqlConnection.CreateCommand();
command.CommandText = scriptAnnotation?.Script ?? $"CREATE DATABASE {quotedDatabaseIdentifier}";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
catch (PostgresException p) when (p.SqlState == "42P04")
{
// Ignore the error if the database already exists.
}
catch (Exception e)
{
var logger = serviceProvider.GetRequiredService<ResourceLoggerService>().GetLogger(npgsqlDatabase.Parent);
logger.LogError(e, "Failed to create database '{DatabaseName}'", npgsqlDatabase.DatabaseName);
}
}
}
4 changes: 2 additions & 2 deletions src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ internal static class PostgresContainerImageTags
/// <remarks>dpage/pgadmin4</remarks>
public const string PgAdminImage = "dpage/pgadmin4";

/// <remarks>8.14</remarks>
public const string PgAdminTag = "8.14";
/// <remarks>9.1.0</remarks>
public const string PgAdminTag = "9.1.0";

/// <remarks>docker.io</remarks>
public const string PgWebRegistry = "docker.io";
Expand Down
14 changes: 12 additions & 2 deletions src/Aspire.Hosting.PostgreSQL/PostgresDatabaseResource.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;

Expand All @@ -23,9 +24,18 @@ public class PostgresDatabaseResource(string name, string databaseName, Postgres
/// <summary>
/// Gets the connection string expression for the Postgres database.
/// </summary>
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"{Parent};Database={DatabaseName}");
public ReferenceExpression ConnectionStringExpression
{
get
{
var connectionStringBuilder = new DbConnectionStringBuilder
{
["Database"] = DatabaseName
};
Comment on lines +31 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's special about this that we need to use the builder? Does it escape properly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, and there is a functional test to validate we can pass database name with special chars to ensure the connection string and the default CREATE DATABASE script are correctly escaping.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it for Sql Server too btw.


return ReferenceExpression.Create($"{Parent};{connectionStringBuilder.ToString()}");
}
}
/// <summary>
/// Gets the database name.
/// </summary>
Expand Down
Loading