Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WithPgWeb #5098

Merged
merged 21 commits into from
Aug 20, 2024
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 @@ -21,6 +21,8 @@
// External resources.
var db10 = builder.AddPostgres("pg10").WithPgAdmin().PublishAsConnectionString().AddDatabase("db10");

var db11 = builder.AddPostgres("pg11").WithPgWeb().AddDatabase("postgres");

builder.AddProject<Projects.PostgresEndToEnd_ApiService>("api")
.WithExternalHttpEndpoints()
.WithReference(db1)
Expand All @@ -32,8 +34,8 @@
.WithReference(db7)
.WithReference(db8)
.WithReference(db9)
.WithReference(db10);

.WithReference(db10)
.WithReference(db11);
#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
// of the dashboard. It is not required in end developer code. Comment out this code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,29 @@
"type": "value.v0",
"connectionString": "{pg10.connectionString};Database=db10"
},
"pg11": {
"type": "container.v0",
"connectionString": "Host={pg11.bindings.tcp.host};Port={pg11.bindings.tcp.port};Username=postgres;Password={pg11-password.value}",
"image": "docker.io/library/postgres:16.2",
"env": {
"POSTGRES_HOST_AUTH_METHOD": "scram-sha-256",
"POSTGRES_INITDB_ARGS": "--auth-host=scram-sha-256 --auth-local=scram-sha-256",
"POSTGRES_USER": "postgres",
"POSTGRES_PASSWORD": "{pg11-password.value}"
},
"bindings": {
"tcp": {
"scheme": "tcp",
"protocol": "tcp",
"transport": "tcp",
"targetPort": 5432
}
}
},
"postgres": {
"type": "value.v0",
"connectionString": "{pg11.connectionString};Database=postgres"
},
"api": {
"type": "project.v0",
"path": "../PostgresEndToEnd.ApiService/PostgresEndToEnd.ApiService.csproj",
Expand All @@ -184,7 +207,8 @@
"ConnectionStrings__db7": "{db7.connectionString}",
"ConnectionStrings__db8": "{db8.connectionString}",
"ConnectionStrings__db9": "{db9.connectionString}",
"ConnectionStrings__db10": "{db10.connectionString}"
"ConnectionStrings__db10": "{db10.connectionString}",
"ConnectionStrings__postgres": "{postgres.connectionString}"
},
"bindings": {
"http": {
Expand Down Expand Up @@ -290,6 +314,21 @@
}
}
}
},
"pg11-password": {
"type": "parameter.v0",
"value": "{pg11-password.inputs.value}",
"inputs": {
"value": {
"type": "string",
"secret": true,
"default": {
"generate": {
"minLength": 22
}
}
}
}
}
}
}
1 change: 1 addition & 0 deletions spelling.dic
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ upsert
uris
urls
kubernetes
Pgweb
22 changes: 22 additions & 0 deletions src/Aspire.Hosting.PostgreSQL/PgWebContainerResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Postgres;

/// <summary>
/// Represents a container resource for pgweb.
/// </summary>
/// <param name="name">The name of the container resource.</param>
public sealed class PgWebContainerResource(string name) : ContainerResource(name)
{
internal const string PrimaryEndpointName = "http";

private EndpointReference? _primaryEndpoint;

/// <summary>
/// Gets the primary endpoint for the pgweb.
/// </summary>
public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName);
}
99 changes: 98 additions & 1 deletion src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static IResourceBuilder<PostgresDatabaseResource> AddDatabase(this IResou
}

/// <summary>
/// Adds a pgAdmin 4 administration and development platform for PostgreSQL to the application model. This version the package defaults to the 8.3 tag of the dpage/pgadmin4 container image
/// Adds a pgAdmin 4 administration and development platform for PostgreSQL to the application model. This version the package defaults to the 8.8 tag of the dpage/pgadmin4 container image
/// </summary>
/// <param name="builder">The PostgreSQL server resource builder.</param>
/// <param name="configureContainer">Callback to configure PgAdmin container resource.</param>
Expand Down Expand Up @@ -170,6 +170,103 @@ public static IResourceBuilder<PgAdminContainerResource> WithHostPort(this IReso
});
}

/// <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>
/// <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&lt;Projects.Api&gt;("api")
/// .WithReference(db);
///
/// builder.Build().Run();
/// </code>
/// </example>
/// <remarks>
/// This version the package defaults to the 0.15.0 tag of the sosedoff/pgweb container image.
/// </remarks>
/// <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;
Copy link
Member

Choose a reason for hiding this comment

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

How does this directory get cleaned up?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

do we have a hook or event when a resource stopped/finished?

I think we should add one API for that in IDistributedApplicationLifecycleHook

Copy link
Member

Choose a reason for hiding this comment

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

This is actually a problem right now. @karolz-ms interested in your thoughts here. We sometimes drop temporary files in disk and I'd like to be able to try and ensure that they are cleaned up. We can probably have some kind of temporary file register that goes and does clean up but its highly likely that some of those files/directories are going to be locked because things like containers are spinning down.

This might be one of those things that DCP could help with?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I think DCP would be a good place for logic that deals with "cleanup" tasks like removing temporary log files.

There are probably many ways to do this, but the simplest one that will work today, is to put them inside the DCP session folder (which the app host runtime knows about). That folder is automatically deleted when DCP is shutting down. Just make sure the name is reasonably unique when creating the file.

Copy link
Member

Choose a reason for hiding this comment

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

(we might be missing an API for the extensions that will tell them what the "temporary folder for the current application run" is)

Copy link
Member

Choose a reason for hiding this comment

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

We could do that too. Feel free to open an issue and we can add it to our backlog for DCP.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

what is our approach for cleanup temp files for this PR?

Copy link
Member

Choose a reason for hiding this comment

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

@mitchdenny - would this be a good usage of the new Eventing API? We could have an event that fires when the resource is shut down.

Copy link
Member

Choose a reason for hiding this comment

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

what is our approach for cleanup temp files for this PR?

Since we already have the same issue with pgadmin:

.WithBindMount(Path.GetTempFileName(), "/pgadmin4/servers.json")
.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);

I'd be fine with continuing here to just create temp files and leave them, for now, until we get the clean up capability.

Copy link
Member

Choose a reason for hiding this comment

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

Yes ... although it wouldn't be 100%. If someone kills the process via the debugger (most common scenario) I don't think we guarantee that this is run. But better than nothing I guess.

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);

Alirexaa marked this conversation as resolved.
Show resolved Hide resolved
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";

var fileContent = $"""
host = "{postgresDatabase.Parent.PrimaryEndpoint.ContainerHost}"
port = {postgresDatabase.Parent.PrimaryEndpoint.Port}
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.
Expand Down
3 changes: 3 additions & 0 deletions src/Aspire.Hosting.PostgreSQL/PostgresContainerImageTags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ internal static class PostgresContainerImageTags
public const string PgAdminRegistry = "docker.io";
public const string PgAdminImage = "dpage/pgadmin4";
public const string PgAdminTag = "8.8";
public const string PgWebRegistry = "docker.io";
public const string PgWebImage = "sosedoff/pgweb";
public const string PgWebTag = "0.15.0";
}
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.PostgreSQL/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
#nullable enable
Aspire.Hosting.Postgres.PgWebContainerResource
Aspire.Hosting.Postgres.PgWebContainerResource.PgWebContainerResource(string! name) -> void
Aspire.Hosting.Postgres.PgWebContainerResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference!
static Aspire.Hosting.PostgresBuilderExtensions.WithHostPort(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Postgres.PgWebContainerResource!>! builder, int? port) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Postgres.PgWebContainerResource!>!
static Aspire.Hosting.PostgresBuilderExtensions.WithPgWeb(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.PostgresServerResource!>! builder, System.Action<Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.Postgres.PgWebContainerResource!>!>? configureContainer = null, string? containerName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.PostgresServerResource!>!

114 changes: 114 additions & 0 deletions tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,48 @@ public void WithPgAdminAddsContainer()
Assert.Equal("/pgadmin4/servers.json", volume.Target);
}

[Fact]
public void WithPgWebAddsWithPgWebResource()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddPostgres("mypostgres1").WithPgWeb();
builder.AddPostgres("mypostgres2").WithPgWeb();

Assert.Single(builder.Resources.OfType<PgWebContainerResource>());
}

[Fact]
public void WithPgWebSupportsChangingContainerImageValues()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddPostgres("mypostgres").WithPgWeb(c =>
{
c.WithImageRegistry("example.mycompany.com");
c.WithImage("customrediscommander");
c.WithImageTag("someothertag");
});

var resource = Assert.Single(builder.Resources.OfType<PgWebContainerResource>());
var containerAnnotation = Assert.Single(resource.Annotations.OfType<ContainerImageAnnotation>());
Assert.Equal("example.mycompany.com", containerAnnotation.Registry);
Assert.Equal("customrediscommander", containerAnnotation.Image);
Assert.Equal("someothertag", containerAnnotation.Tag);
}

[Fact]
public void WithRedisInsightSupportsChangingHostPort()
{
var builder = DistributedApplication.CreateBuilder();
builder.AddPostgres("mypostgres").WithPgWeb(c =>
{
c.WithHostPort(1000);
});

var resource = Assert.Single(builder.Resources.OfType<PgWebContainerResource>());
var endpoint = Assert.Single(resource.Annotations.OfType<EndpointAnnotation>());
Assert.Equal(1000, endpoint.Port);
}

[Fact]
public void WithPgAdminWithCallbackMutatesImage()
{
Expand Down Expand Up @@ -444,6 +486,62 @@ public async Task WithPostgresProducesValidServersJsonFile(string containerHost)
Assert.Equal($"echo '{pg2.Resource.PasswordParameter.Value}'", servers.GetProperty("2").GetProperty("PasswordExecCommand").GetString());
}

[Theory]
[InlineData("host.docker.internal")]
[InlineData("host.containers.internal")]
public async Task WithPgwebProducesValidBookmarkFiles(string containerHost)
{
var builder = DistributedApplication.CreateBuilder();
var pg1 = builder.AddPostgres("mypostgres1").WithPgWeb(pga => pga.WithHostPort(8081));
var pg2 = builder.AddPostgres("mypostgres2").WithPgWeb(pga => pga.WithHostPort(8081));

// Add fake allocated endpoints.
pg1.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5001, containerHost));
pg2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002, "host2"));

var db1 = pg1.AddDatabase("db1");
var db2 = pg2.AddDatabase("db2");

var pgadmin = builder.Resources.Single(r => r.Name.EndsWith("-pgweb"));
var volume = pgadmin.Annotations.OfType<ContainerMountAnnotation>().Single();

using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();

await builder.Eventing.PublishAsync<AfterEndpointsAllocatedEvent>(new(app.Services, app.Services.GetRequiredService<DistributedApplicationModel>()));

var bookMarkFiles = Directory
.GetFiles(volume.Source!);

Assert.Collection(bookMarkFiles,
filePath =>
{
Assert.Equal(".toml", Path.GetExtension(filePath)) ;
},
filePath =>
{
Assert.Equal(".toml", Path.GetExtension(filePath));
});

var bookmarkFilesContent = new List<string>();

foreach (var filePath in bookMarkFiles)
{
bookmarkFilesContent.Add(File.ReadAllText(filePath));
}

Assert.NotEmpty(bookmarkFilesContent);
Assert.Collection(bookmarkFilesContent,
content =>
{
Assert.Equal(CreatePgWebBookmarkfileContent(db1.Resource), content);
},
content =>
{
Assert.Equal(CreatePgWebBookmarkfileContent(db2.Resource), content);
});
}

[Fact]
public void ThrowsWithIdenticalChildResourceNames()
{
Expand Down Expand Up @@ -501,4 +599,20 @@ public void CanAddDatabasesWithTheSameNameOnMultipleServers()
Assert.Equal("{postgres1.connectionString};Database=imports", db1.Resource.ConnectionStringExpression.ValueExpression);
Assert.Equal("{postgres2.connectionString};Database=imports", db2.Resource.ConnectionStringExpression.ValueExpression);
}

private static string CreatePgWebBookmarkfileContent(PostgresDatabaseResource postgresDatabase)
{
var user = postgresDatabase.Parent.UserNameParameter?.Value ?? "postgres";

var fileContent = $"""
host = "{postgresDatabase.Parent.PrimaryEndpoint.ContainerHost}"
port = {postgresDatabase.Parent.PrimaryEndpoint.Port}
user = "{user}"
password = "{postgresDatabase.Parent.PasswordParameter.Value}"
database = "{postgresDatabase.DatabaseName}"
sslmode = "disable"
""";

return fileContent;
}
}
Loading