-
Notifications
You must be signed in to change notification settings - Fork 531
/
Copy pathPostgresBuilderExtensions.cs
396 lines (345 loc) · 22 KB
/
PostgresBuilderExtensions.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Postgres;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding PostgreSQL resources to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static class PostgresBuilderExtensions
{
private const string UserEnvVarName = "POSTGRES_USER";
private const string PasswordEnvVarName = "POSTGRES_PASSWORD";
/// <summary>
/// Adds a PostgreSQL resource to the application model. A container is used for local development.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
/// <param name="userName">The parameter used to provide the user name for the PostgreSQL resource. If <see langword="null"/> a default value will be used.</param>
/// <param name="password">The parameter used to provide the administrator password for the PostgreSQL resource. If <see langword="null"/> a random password will be generated.</param>
/// <param name="port">The host port used when launching the container. If null a random port will be assigned.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This resource includes built-in health checks. When this resource is referenced as a dependency
/// using the <see cref="ResourceBuilderExtensions.WaitFor{T}(IResourceBuilder{T}, IResourceBuilder{IResource})"/>
/// extension method then the dependent resource will wait until the Postgres resource is able to service
/// requests.
/// </para>
/// This version of the package defaults to the <inheritdoc cref="PostgresContainerImageTags.Tag"/> tag of the <inheritdoc cref="PostgresContainerImageTags.Image"/> container image.
/// </remarks>
public static IResourceBuilder<PostgresServerResource> AddPostgres(this IDistributedApplicationBuilder builder,
[ResourceName] string name,
IResourceBuilder<ParameterResource>? userName = null,
IResourceBuilder<ParameterResource>? password = null,
int? port = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
var passwordParameter = password?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-password");
var postgresServer = new PostgresServerResource(name, userName?.Resource, passwordParameter);
string? connectionString = null;
builder.Eventing.Subscribe<ConnectionStringAvailableEvent>(postgresServer, async (@event, ct) =>
{
connectionString = await postgresServer.GetConnectionStringAsync(ct).ConfigureAwait(false);
if (connectionString == null)
{
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{postgresServer.Name}' resource but the connection string was null.");
}
});
var healthCheckKey = $"{name}_check";
builder.Services.AddHealthChecks().AddNpgSql(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey, configure: (connection) =>
{
// HACK: The Npgsql client defaults to using the username in the connection string if the database is not specified. Here
// we override this default behavior because we are working with a non-database scoped connection string. The Aspirified
// package doesn't have to deal with this because it uses a datasource from DI which doesn't have this issue:
//
// https://github.com/npgsql/npgsql/blob/c3b31c393de66a4b03fba0d45708d46a2acb06d2/src/Npgsql/NpgsqlConnection.cs#L445
//
connection.ConnectionString += ";Database=postgres;";
});
return builder.AddResource(postgresServer)
.WithEndpoint(port: port, targetPort: 5432, name: PostgresServerResource.PrimaryEndpointName) // Internal port is always 5432.
.WithImage(PostgresContainerImageTags.Image, PostgresContainerImageTags.Tag)
.WithImageRegistry(PostgresContainerImageTags.Registry)
.WithEnvironment("POSTGRES_HOST_AUTH_METHOD", "scram-sha-256")
.WithEnvironment("POSTGRES_INITDB_ARGS", "--auth-host=scram-sha-256 --auth-local=scram-sha-256")
.WithEnvironment(context =>
{
context.EnvironmentVariables[UserEnvVarName] = postgresServer.UserNameReference;
context.EnvironmentVariables[PasswordEnvVarName] = postgresServer.PasswordParameter;
})
.WithHealthCheck(healthCheckKey);
}
/// <summary>
/// Adds a PostgreSQL database to the application model.
/// </summary>
/// <param name="builder">The PostgreSQL server resource builder.</param>
/// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
/// <param name="databaseName">The name of the database. If not provided, this defaults to the same value as <paramref name="name"/>.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This resource includes built-in health checks. When this resource is referenced as a dependency
/// using the <see cref="ResourceBuilderExtensions.WaitFor{T}(IResourceBuilder{T}, IResourceBuilder{IResource})"/>
/// extension method then the dependent resource will wait until the Postgres database is available.
/// </para>
/// <para>
/// Note that by default calling <see cref="AddDatabase(IResourceBuilder{PostgresServerResource}, string, string?)"/>
/// does not result in the database being created on the Postgres server. It is expected that code within your solution
/// will create the database. As a result if <see cref="ResourceBuilderExtensions.WaitFor{T}(IResourceBuilder{T}, IResourceBuilder{IResource})"/>
/// is used with this resource it will wait indefinitely until the database exists.
/// </para>
/// </remarks>
public static IResourceBuilder<PostgresDatabaseResource> AddDatabase(this IResourceBuilder<PostgresServerResource> builder, [ResourceName] string name, string? databaseName = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
// 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);
}
/// <summary>
/// Adds a pgAdmin 4 administration and development platform for PostgreSQL to the application model.
/// </summary>
/// <remarks>
/// This version of the package defaults to the <inheritdoc cref="PostgresContainerImageTags.PgAdminTag"/> tag of the <inheritdoc cref="PostgresContainerImageTags.PgAdminImage"/> container image.
/// </remarks>
/// <param name="builder">The PostgreSQL server resource builder.</param>
/// <param name="configureContainer">Callback to configure PgAdmin container resource.</param>
/// <param name="containerName">The name of the container (Optional).</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<T> WithPgAdmin<T>(this IResourceBuilder<T> builder, Action<IResourceBuilder<PgAdminContainerResource>>? configureContainer = null, string? containerName = null) where T : PostgresServerResource
{
ArgumentNullException.ThrowIfNull(builder);
if (builder.ApplicationBuilder.Resources.OfType<PgAdminContainerResource>().SingleOrDefault() is { } existingPgAdminResource)
{
var builderForExistingResource = builder.ApplicationBuilder.CreateResourceBuilder(existingPgAdminResource);
configureContainer?.Invoke(builderForExistingResource);
return builder;
}
else
{
containerName ??= $"{builder.Resource.Name}-pgadmin";
var pgAdminContainer = new PgAdminContainerResource(containerName);
var pgAdminContainerBuilder = builder.ApplicationBuilder.AddResource(pgAdminContainer)
.WithImage(PostgresContainerImageTags.PgAdminImage, PostgresContainerImageTags.PgAdminTag)
.WithImageRegistry(PostgresContainerImageTags.PgAdminRegistry)
.WithHttpEndpoint(targetPort: 80, name: "http")
.WithEnvironment(SetPgAdminEnvironmentVariables)
.WithBindMount(Path.GetTempFileName(), "/pgadmin4/servers.json")
.WithHttpHealthCheck("/browser")
.ExcludeFromManifest();
builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>((e, ct) =>
{
var serverFileMount = pgAdminContainer.Annotations.OfType<ContainerMountAnnotation>().Single(v => v.Target == "/pgadmin4/servers.json");
var postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresServerResource>();
var serverFileBuilder = new StringBuilder();
using var stream = new FileStream(serverFileMount.Source!, FileMode.Create);
using var writer = new Utf8JsonWriter(stream);
// Need to grant read access to the config file on unix like systems.
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(serverFileMount.Source!, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
}
var serverIndex = 1;
writer.WriteStartObject();
writer.WriteStartObject("Servers");
foreach (var postgresInstance in postgresInstances)
{
if (postgresInstance.PrimaryEndpoint.IsAllocated)
{
var endpoint = postgresInstance.PrimaryEndpoint;
writer.WriteStartObject($"{serverIndex}");
writer.WriteString("Name", postgresInstance.Name);
writer.WriteString("Group", "Servers");
// PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address
// This will need to be refactored once updated service discovery APIs are available
writer.WriteString("Host", endpoint.Resource.Name);
writer.WriteNumber("Port", (int)endpoint.TargetPort!);
writer.WriteString("Username", postgresInstance.UserNameParameter?.Value ?? "postgres");
writer.WriteString("SSLMode", "prefer");
writer.WriteString("MaintenanceDB", "postgres");
writer.WriteString("PasswordExecCommand", $"echo '{postgresInstance.PasswordParameter.Value}'"); // HACK: Generating a pass file and playing around with chmod is too painful.
writer.WriteEndObject();
}
serverIndex++;
}
writer.WriteEndObject();
writer.WriteEndObject();
return Task.CompletedTask;
});
configureContainer?.Invoke(pgAdminContainerBuilder);
pgAdminContainerBuilder.WithRelationship(builder.Resource, "PgAdmin");
return builder;
}
}
/// <summary>
/// Configures the host port that the PGAdmin resource is exposed on instead of using randomly assigned port.
/// </summary>
/// <param name="builder">The resource builder for PGAdmin.</param>
/// <param name="port">The port to bind on the host. If <see langword="null"/> is used random port will be assigned.</param>
/// <returns>The resource builder for PGAdmin.</returns>
public static IResourceBuilder<PgAdminContainerResource> WithHostPort(this IResourceBuilder<PgAdminContainerResource> builder, int? port)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithEndpoint("http", endpoint =>
{
endpoint.Port = port;
});
}
/// <summary>
/// Configures the host port that the pgweb resource is exposed on instead of using randomly assigned port.
/// </summary>
/// <param name="builder">The resource builder for pgweb.</param>
/// <param name="port">The port to bind on the host. If <see langword="null"/> is used random port will be assigned.</param>
/// <returns>The resource builder for pgweb.</returns>
public static IResourceBuilder<PgWebContainerResource> WithHostPort(this IResourceBuilder<PgWebContainerResource> builder, int? port)
{
return builder.WithEndpoint("http", endpoint =>
{
endpoint.Port = port;
});
}
/// <summary>
/// Adds an administration and development platform for PostgreSQL to the application model using pgweb.
/// </summary>
/// <remarks>
/// This version of the package defaults to the <inheritdoc cref="PostgresContainerImageTags.PgWebTag"/> tag of the <inheritdoc cref="PostgresContainerImageTags.PgWebImage"/> container image.
/// </remarks>
/// <param name="builder">The Postgres server resource builder.</param>
/// <param name="configureContainer">Configuration callback for pgweb container resource.</param>
/// <param name="containerName">The name of the container (Optional).</param>
/// <example>
/// Use in application host with a Postgres resource
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var postgres = builder.AddPostgres("postgres")
/// .WithPgWeb();
/// var db = postgres.AddDatabase("db");
///
/// var api = builder.AddProject<Projects.Api>("api")
/// .WithReference(db);
///
/// builder.Build().Run();
/// </code>
/// </example>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<PostgresServerResource> WithPgWeb(this IResourceBuilder<PostgresServerResource> builder, Action<IResourceBuilder<PgWebContainerResource>>? configureContainer = null, string? containerName = null)
{
if (builder.ApplicationBuilder.Resources.OfType<PgWebContainerResource>().SingleOrDefault() is { } existingPgWebResource)
{
var builderForExistingResource = builder.ApplicationBuilder.CreateResourceBuilder(existingPgWebResource);
configureContainer?.Invoke(builderForExistingResource);
return builder;
}
else
{
containerName ??= $"{builder.Resource.Name}-pgweb";
var dir = Directory.CreateTempSubdirectory().FullName;
var pgwebContainer = new PgWebContainerResource(containerName);
var pgwebContainerBuilder = builder.ApplicationBuilder.AddResource(pgwebContainer)
.WithImage(PostgresContainerImageTags.PgWebImage, PostgresContainerImageTags.PgWebTag)
.WithImageRegistry(PostgresContainerImageTags.PgWebRegistry)
.WithHttpEndpoint(targetPort: 8081, name: "http")
.WithBindMount(dir, "/.pgweb/bookmarks")
.WithArgs("--bookmarks-dir=/.pgweb/bookmarks")
.WithArgs("--sessions")
.ExcludeFromManifest();
configureContainer?.Invoke(pgwebContainerBuilder);
pgwebContainerBuilder.WithRelationship(builder.Resource, "PgWeb");
builder.ApplicationBuilder.Eventing.Subscribe<AfterEndpointsAllocatedEvent>(async (e, ct) =>
{
var adminResource = builder.ApplicationBuilder.Resources.OfType<PgWebContainerResource>().Single();
var serverFileMount = adminResource.Annotations.OfType<ContainerMountAnnotation>().Single(v => v.Target == "/.pgweb/bookmarks");
var postgresInstances = builder.ApplicationBuilder.Resources.OfType<PostgresDatabaseResource>();
if (!Directory.Exists(serverFileMount.Source!))
{
Directory.CreateDirectory(serverFileMount.Source!);
}
foreach (var postgresDatabase in postgresInstances)
{
var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres";
// PgAdmin assumes Postgres is being accessed over a default Aspire container network and hardcodes the resource address
// This will need to be refactored once updated service discovery APIs are available
var fileContent = $"""
host = "{postgresDatabase.Parent.Name}"
port = {postgresDatabase.Parent.PrimaryEndpoint.TargetPort}
user = "{user}"
password = "{postgresDatabase.Parent.PasswordParameter.Value}"
database = "{postgresDatabase.DatabaseName}"
sslmode = "disable"
""";
var filePath = Path.Combine(serverFileMount.Source!, $"{postgresDatabase.Name}.toml");
await File.WriteAllTextAsync(filePath, fileContent, ct).ConfigureAwait(false);
}
});
return builder;
}
}
private static void SetPgAdminEnvironmentVariables(EnvironmentCallbackContext context)
{
// Disables pgAdmin authentication.
context.EnvironmentVariables.Add("PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED", "False");
context.EnvironmentVariables.Add("PGADMIN_CONFIG_SERVER_MODE", "False");
// You need to define the PGADMIN_DEFAULT_EMAIL and PGADMIN_DEFAULT_PASSWORD or PGADMIN_DEFAULT_PASSWORD_FILE environment variables.
context.EnvironmentVariables.Add("PGADMIN_DEFAULT_EMAIL", "[email protected]");
context.EnvironmentVariables.Add("PGADMIN_DEFAULT_PASSWORD", "admin");
// When running in the context of Codespaces we need to set some additional environment
// variables so that PGAdmin will trust the forwarded headers that Codespaces port
// forwarding will send.
var config = context.ExecutionContext.ServiceProvider.GetRequiredService<IConfiguration>();
if (context.ExecutionContext.IsRunMode && config.GetValue<bool>("CODESPACES", false))
{
context.EnvironmentVariables["PGADMIN_CONFIG_PROXY_X_HOST_COUNT"] = "1";
context.EnvironmentVariables["PGADMIN_CONFIG_PROXY_X_PREFIX_COUNT"] = "1";
}
}
/// <summary>
/// Adds a named volume for the data folder to a PostgreSQL container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="name">The name of the volume. Defaults to an auto-generated name based on the application and resource names.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only volume.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<PostgresServerResource> WithDataVolume(this IResourceBuilder<PostgresServerResource> builder, string? name = null, bool isReadOnly = false)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"),
"/var/lib/postgresql/data", isReadOnly);
}
/// <summary>
/// Adds a bind mount for the data folder to a PostgreSQL container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="source">The source directory on the host to mount into the container.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only mount.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<PostgresServerResource> WithDataBindMount(this IResourceBuilder<PostgresServerResource> builder, string source, bool isReadOnly = false)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(source);
return builder.WithBindMount(source, "/var/lib/postgresql/data", isReadOnly);
}
/// <summary>
/// Adds a bind mount for the init folder to a PostgreSQL container resource.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="source">The source directory on the host to mount into the container.</param>
/// <param name="isReadOnly">A flag that indicates if this is a read-only mount.</param>
/// <returns>The <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<PostgresServerResource> WithInitBindMount(this IResourceBuilder<PostgresServerResource> builder, string source, bool isReadOnly = true)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(source);
return builder.WithBindMount(source, "/docker-entrypoint-initdb.d", isReadOnly);
}
}